+
+
-
- 404
+ Oh noes!
-
+
We couldn't find that page - it might have been moved or deleted.
-
-
- Here's some ideas on how to find it:
-
- -
- Make sure there's no typos in the URL.
-
- -
- Use the search box above to try and find the page.
-
- -
- The page might not exist yet!
-
-
- Alternatively,
return to the main page instead.
-
+
+
+
+ Here's some ideas on how to find it:
+
+ -
+ Make sure there's no typos in the URL.
+
+ -
+ Use the search box above to try and find the page.
+
+ -
+ The page might not exist yet!
+
+
+ Alternatively, return to the main page instead.
+
+
{% endblock %}
-{% block disqus %}{% endblock %}
\ No newline at end of file
+{% block disqus %}{% endblock %}
+
+{% block site_nav %}
+
+ {% if nav %}
+ {% if page.meta and page.meta.hide %}
+ {% set hidden = "hidden" if "navigation" in page.meta.hide %}
+ {% endif %}
+
+ {% endif %}
+{% endblock %}
\ No newline at end of file
diff --git a/docs/api-reference/errors/error-paste-box.js b/docs/assets/scripts/error-paste-box.js
similarity index 90%
rename from docs/api-reference/errors/error-paste-box.js
rename to docs/assets/scripts/error-paste-box.js
index 76487a5fd..d060d48b2 100644
--- a/docs/api-reference/errors/error-paste-box.js
+++ b/docs/assets/scripts/error-paste-box.js
@@ -48,6 +48,4 @@ try {
} catch(e) {
alert("Couldn't instantiate the error paste box - " + e);
-}
-
-const input = "[Fusion] Computed callback error: attempt to index nil with 'get' (ID: computedCallbackError) ---- Stack trace ---- Replica";
\ No newline at end of file
+}
\ No newline at end of file
diff --git a/docs/assets/theme/404.css b/docs/assets/theme/404.css
index 1e6a0d140..06b0931a4 100644
--- a/docs/assets/theme/404.css
+++ b/docs/assets/theme/404.css
@@ -1,13 +1,40 @@
-.fusion-404 h1 {
- display: flex;
- align-items: center;
- justify-content: left;
+#fusion-404 .graphic {
+ display: block;
+ width: 16rem;
+ max-width: 80vmin;
+ aspect-ratio: 1;
+ margin: 0 auto;
+ margin-bottom: 2rem;
+ background-size: contain;
+ background-position: center;
+ background-repeat: no-repeat;
+}
+
+[data-md-color-scheme="fusiondoc-light"] #fusion-404 .graphic {
+ background-image: url("../404-light.svg");
+}
+
+[data-md-color-scheme="fusiondoc-dark"] #fusion-404 .graphic {
+ background-image: url("../404-dark.svg");
+}
+
+#fusion-404 h1 {
+ text-align: center;
font-size: 2rem;
+ margin-top: 0;
margin-bottom: 1rem;
}
-.fusion-404 h1 img {
- width: 6rem;
- height: 6rem;
- margin-right: 2rem;
+#fusion-404 h2 {
+ text-align: center;
+ font-size: 1rem;
+ font-weight: 400;
+
+ margin-top: 0;
+ margin-bottom: 2rem;
}
+
+#fusion-404 .advice {
+ max-width: max-content;
+ margin: 0 auto;
+}
\ No newline at end of file
diff --git a/docs/assets/theme/api-reference.css b/docs/assets/theme/api-reference.css
index a1667c190..df32bd7f2 100644
--- a/docs/assets/theme/api-reference.css
+++ b/docs/assets/theme/api-reference.css
@@ -1,88 +1,91 @@
.fusiondoc-api-header {
display: flex;
- align-items: center;
- gap: 0.75rem;
+ align-items: baseline;
margin: 0 !important;
flex-wrap: wrap;
}
-.fusiondoc-api-header .headerlink {
- display: none;
+.fusiondoc-api-header .fusiondoc-api-icon {
+ margin-right: 0.5em;
}
.fusiondoc-api-header > * {
flex-shrink: 0;
}
-.fusiondoc-api-header .fusiondoc-api-name {
- margin-right: auto;
+.fusiondoc-api-type {
+ vertical-align: middle;
+ font-size: calc((0.5em + 1rem) / 2);
+ flex-shrink: 0;
+
+ margin-left: 0.5em;
+
+ font-family: var(--md-code-font-family);
+ font-weight: 400;
+}
+
+.fusiondoc-api-type:not(a){
+ opacity: 0.8;
}
-.fusiondoc-api-pills {
+.fusiondoc-api-breadcrumbs {
display: flex;
align-items: center;
- justify-content: flex-end;
- gap: 0.5em;
- flex-wrap: wrap;
- line-height: 1.5em;
-
- font-size: 0.75rem;
- margin-left: 0.5em;
- margin-bottom: -2em;
+ height: 2rem;
+}
- letter-spacing: -0.02em;
- font-weight: 400;
- vertical-align: middle;
+.fusiondoc-api-breadcrumbs {
+ opacity: 0.8;
}
-.fusiondoc-api-pills + h1,
-.fusiondoc-api-pills + h2,
-.fusiondoc-api-pills + h3,
-.fusiondoc-api-pills + h4 {
- margin-top: 0;
+.fusiondoc-api-breadcrumbs > *:not(:last-child)::after {
+ content: "›";
+ margin: 0 0.25rem;
}
-.fusiondoc-api-header .fusiondoc-api-pills {
- font-size: 0.5em;
- margin-bottom: 0em;
+.fusiondoc-error-api-section {
+ opacity: 1;
+ transition: opacity 0.4s ease, filter 0.4s ease;
}
-.fusiondoc-api-pills > * {
- padding: 0.25em 0.75em;
- border-radius: 0.25rem;
- height: 2em;
- flex-shrink: 0;
+.fusiondoc-error-api-section.fusiondoc-error-api-section-defocus {
+ opacity: 0.4;
+ filter: blur(0.25rem);
+ pointer-events: none;
}
-.fusiondoc-api-pills .fusiondoc-api-pill-type {
- background-color: var(--fusiondoc-orange-a20);
- color: var(--fusiondoc-accent-text);
+.fusiondoc-api-bento {
+ column-width: 12rem;
+ column-gap: 0.5rem;
}
-.fusiondoc-api-pills .fusiondoc-api-pill-since {
- background-color: var(--fusiondoc-grey-a20);
- color: var(--fusiondoc-fg-1);
+.fusiondoc-api-bento > * {
+ break-inside: avoid;
+ background-color: var(--fusiondoc-bg-2);
+ box-shadow: var(--md-shadow-z1);
+ border-radius: 0.25rem;
+ padding: 0.75rem 1rem;
+ padding-bottom: 0.01rem;
+ margin: 0.5rem 0;
+ margin-top: 0;
}
-.fusiondoc-api-breadcrumbs {
- display: flex;
- align-items: center;
- height: 2rem;
+.fusiondoc-api-bento > * :first-child {
+ margin-top: 0 !important;
}
-.fusiondoc-api-breadcrumbs > a {
+.fusiondoc-api-bento > * > h3 {
+ font-size: 0.7rem;
color: var(--fusiondoc-fg-3);
+ font-weight: 700;
}
-.fusiondoc-api-breadcrumbs > a:hover {
- color: var(--fusiondoc-accent-hover);
+.fusiondoc-api-index-header {
+ color: var(--fusiondoc-fg-1) !important;
}
-.fusiondoc-api-breadcrumbs > a::after {
- content: "/";
- margin: 0 0.25rem;
- opacity: 0.5;
- color: var(--fusiondoc-fg-3);
+.fusiondoc-api-index-header:hover {
+ color: var(--fusiondoc-accent-hover) !important;
}
.fusiondoc-api-index-link {
@@ -99,20 +102,11 @@
}
.fusiondoc-api-index-link > .fusiondoc-api-index-arrow {
+ margin-bottom: -0.3em;
transform: translateX(0rem);
transition: transform 0.2s ease;
}
.fusiondoc-api-index-link:hover > .fusiondoc-api-index-arrow {
transform: translateX(0.5rem);
-}
-
-.fusiondoc-error-api-section {
- opacity: 1;
- transition: opacity 0.4s ease;
-}
-
-.fusiondoc-error-api-section.fusiondoc-error-api-section-defocus {
- opacity: 0.4;
- transition: opacity 0.4s ease;
}
\ No newline at end of file
diff --git a/docs/assets/theme/home.css b/docs/assets/theme/home.css
index 6eb6dc615..8b38869f6 100644
--- a/docs/assets/theme/home.css
+++ b/docs/assets/theme/home.css
@@ -41,6 +41,17 @@
background-repeat: no-repeat;
background-size: 177vh 100vh;
background-position: center right;
+ animation: home-fade-in-bg 0.5s ease 0.5s;
+ animation-fill-mode: forwards;
+}
+
+@keyframes home-fade-in-bg {
+ 0% {
+ transform: translateY(-0.8rem);
+ }
+ 100% {
+ transform: translateY(0rem);
+ }
}
[data-md-color-scheme="fusiondoc-dark"] #fusiondoc-home-main::before {
diff --git a/docs/assets/theme/page.css b/docs/assets/theme/page.css
index 0659e02f3..703bd121c 100644
--- a/docs/assets/theme/page.css
+++ b/docs/assets/theme/page.css
@@ -169,6 +169,10 @@
margin-right: 0rem;
}
+.md-header__title .md-header__topic:first-child {
+ height: 2.4rem;
+}
+
.md-header__title .md-header__topic:first-child::before {
content: "";
display: inline-block;
@@ -238,7 +242,7 @@
}
.md-copyright::after {
- content: "∙ Theme by Elttob";
+ content: "∙ Theme by Daniel P H Fox";
}
@media screen and (max-width: 76.1875em) {
diff --git a/docs/assets/theme/paragraph.css b/docs/assets/theme/paragraph.css
index a853eab0e..a3937e819 100644
--- a/docs/assets/theme/paragraph.css
+++ b/docs/assets/theme/paragraph.css
@@ -86,13 +86,4 @@ h6 > .twemoji:first-child {
font-style: normal;
font-size: 0.8em;
letter-spacing: 0.02em;
-}
-
-.fusiondoc-index-multicol {
- column-width: 15em;
-}
-
-.fusiondoc-index-multicol .fusiondoc-index-multicol-section {
- break-inside: avoid;
- padding-top: 0.001em; /* prevent margins collapsing */
}
\ No newline at end of file
diff --git a/docs/examples/cookbook/animated-computed.md b/docs/examples/cookbook/animated-computed.md
index a460dadf6..414a5403e 100644
--- a/docs/examples/cookbook/animated-computed.md
+++ b/docs/examples/cookbook/animated-computed.md
@@ -1,43 +1,114 @@
+This example shows you how to animate a single value with an animation curve of
+your preference.
+
+For demonstration, the example uses Roblox API members.
+
+-----
+
+## Overview
+
```Lua linenums="1"
--- [Fusion imports omitted for clarity]
+local Players = game:GetService("Players")
--- Oftentimes we calculate values for a single purpose, such as the position of
--- a single UI element. These values are often calculated inline, like this:
+local Fusion = -- initialise Fusion here however you please!
+local scoped = Fusion.scoped
+local Children = Fusion.Children
-local menuBar = New "Frame" {
- AnchorPoint = Computed(function(use)
- return if use(menuIsOpen) then Vector2.new(0.5, 0) else Vector2.new(0.5, -1)
- end)
-}
+local TWEEN_INFO = TweenInfo.new(
+ 0.5,
+ Enum.EasingStyle.Sine,
+ Enum.EasingDirection.InOut
+)
--- If you want to animate these inline values, you can pass them through an
--- object such as Spring and Tween- you don't have to do it separately.
+-- Don't forget to pass this to `doCleanup` if you disable the script.
+local scope = scoped(Fusion)
-local menuBar = New "Frame" {
- -- Use tweens for highly controllable animations:
- AnchorPoint = Tween(Computed(function(use)
- return if use(menuIsOpen) then Vector2.new(0.5, 0) else Vector2.new(0.5, -1)
- end), TweenInfo.new(0.2, Enum.EasingStyle.Sine, Enum.EasingDirection.InOut)),
+-- You can set this at any time to indicate where The Thing should be.
+local showTheThing = scope:Value(false)
- -- Or use springs for more natural and responsive movement:
- AnchorPoint = Spring(Computed(function(use)
- return if use(menuIsOpen) then Vector2.new(0.5, 0) else Vector2.new(0.5, -1)
- end), 20, 0.5)
-}
+local exampleUI = scope:New "ScreenGui" {
+ Parent = Players.LocalPlayer:FindFirstChildOfClass("PlayerGui"),
+ Name = "Example UI",
--- The equivalent 'expanded' code looks like this:
+ [Children] = scope:New "Frame" {
+ Name = "The Thing",
+ Position = scope:Tween(
+ scope:Computed(function(use)
+ local CENTRE = UDim2.fromScale(0.5, 0.5)
+ local OFFSCREEN = UDim2.fromScale(-0.5, 0.5)
+ return if use(showTheThing) then CENTRE else OFFSCREEN
+ end),
+ TWEEN_INFO
+ ),
+ Size = UDim2.fromOffset(200, 200)
+ }
+}
-local anchorPoint = Computed(function(use)
- return if use(menuIsOpen) then Vector2.new(0.5, 0) else Vector2.new(0.5, -1)
+-- Without toggling the value, you won't see it animate.
+task.defer(function()
+ while true do
+ task.wait(1)
+ showTheThing:set(not peek(showTheThing))
+ end
end)
+```
-local smoothAnchorPoint = Spring(anchorPoint, 20, 0.5) -- or equivalent Tween
+-----
-local menuBar = New "Frame" {
- AnchorPoint = smoothAnchorPoint
-}
+## Explanation
+
+There's three key components to the above code snippet.
+
+Firstly, there's `showTheThing`. When this is `true`, The Thing should be in
+the centre of the screen. Otherwise, The Thing should be off-screen.
+
+```Lua linenums="13"
+-- You can set this at any time to indicate where The Thing should be.
+local showTheThing = scope:Value(false)
+```
+
+Next, there's the computed object on line 26. This takes that boolean value, and
+turns it into a UDim2 position for The Thing to use. You can imagine this as the
+'non-animated' version of what you want The Thing to do, if it were to instantly
+teleport around.
+
+```Lua linenums="26"
+ scope:Computed(function(use)
+ local CENTRE = UDim2.fromScale(0.5, 0.5)
+ local OFFSCREEN = UDim2.fromScale(-0.5, 0.5)
+ return if use(showTheThing) then CENTRE else OFFSCREEN
+ end),
+```
+
+Finally, there's the tween object that the computed is being passed into. The
+tween object will smoothly move towards the computed over time. If needed, you
+could separate the computed into a dedicated variable to access it
+independently.
+
+```Lua linenums="25"
+ Position = scope:Tween(
+ scope:Computed(function(use)
+ local CENTRE = UDim2.fromScale(0.5, 0.5)
+ local OFFSCREEN = UDim2.fromScale(-0.5, 0.5)
+ return if use(showTheThing) then CENTRE else OFFSCREEN
+ end),
+ TWEEN_INFO
+ ),
+```
+
+The 'shape' of the animation is saved in a `TWEEN_INFO` constant defined earlier
+in the code. [The Tween tutorial](../../../tutorials/animation/tweens) explains
+how each parameter shapes the motion.
+
+```Lua linenums="7"
+local TWEEN_INFO = TweenInfo.new(
+ 0.5,
+ Enum.EasingStyle.Sine,
+ Enum.EasingDirection.InOut
+)
+```
--- Keep in mind that you probably shouldn't use inline animation for everything.
--- Sometimes you need to use the expanded form, or the expanded form would be
--- more efficient, and that's okay - choose what works best for your code :)
-```
\ No newline at end of file
+!!! tip "Fluid animations with springs"
+ For extra smooth animation shapes that preserve velocity, consider trying
+ [spring objects](../../../tutorials/animation/springs). They're very similar
+ in usage and can help improve the responsiveness of the motion.
\ No newline at end of file
diff --git a/docs/examples/cookbook/button-component.md b/docs/examples/cookbook/button-component.md
index 848222743..942315266 100644
--- a/docs/examples/cookbook/button-component.md
+++ b/docs/examples/cookbook/button-component.md
@@ -1,116 +1,180 @@
+This example is a relatively complete button component implemented using
+Fusion's Roblox API. It handles many common interactions such as hovering and
+clicking.
+
+This should be a generally useful template for assembling components of your
+own. For further ideas and best practices for building components, see
+[the Components tutorial](../../../tutorials/components/components).
+
+
+-----
+
+## Overview
+
```Lua linenums="1"
--- [Fusion imports omitted for clarity]
-
--- This is a relatively complete example of a button component.
--- It handles many common interactions such as hovering and clicking.
-
--- This should be a generally useful template for assembling components of your
--- own. Unless you're prototyping, it's probably wise to stick to some good
--- guidelines; the Tutorials have some tips if you don't have any existing
--- guidelines of your own.
-
--- Defining the names of properties the button accepts, and their types. This is
--- useful for autocomplete and helps catch some typos, but is optional.
-export type Props = {
- -- some generic properties we'll allow other code to control directly
- Name: CanBeState
?,
- LayoutOrder: CanBeState?,
- Position: CanBeState?,
- AnchorPoint: CanBeState?,
- Size: CanBeState?,
- AutomaticSize: CanBeState?,
- ZIndex: CanBeState?,
-
- -- button-specific properties
- Text: CanBeState?,
- OnClick: (() -> ())?,
- Disabled: CanBeState?
-}
-
--- Returns `Child` to match Fusion's `Component` type. This should work for most
--- use cases, and offers the greatest encapsulation as you're able to swap out
--- your return type for an array or state object if you want to.
-local function Button(props: Props): Child
- -- We should generally be careful about storing state in widely reused
- -- components, as the Tutorials explain, but for contained use cases such as
- -- hover states, it should be perfectly fine.
- local isHovering = Value(false)
- local isHeldDown = Value(false)
-
- return New "TextButton" {
+local Fusion = -- initialise Fusion here however you please!
+local scoped = Fusion.scoped
+local Children, OnEvent = Fusion.Children, Fusion.OnEvent
+
+local COLOUR_BLACK = Color3.new(0, 0, 0)
+local COLOUR_WHITE = Color3.new(1, 1, 1)
+
+local COLOUR_TEXT = COLOUR_WHITE
+local COLOUR_BG_REST = Color3.fromHex("0085FF")
+local COLOUR_BG_HOVER = COLOUR_BG_REST:Lerp(COLOUR_WHITE, 0.25)
+local COLOUR_BG_HELD = COLOUR_BG_REST:Lerp(COLOUR_BLACK, 0.25)
+local COLOUR_BG_DISABLED = Color3.fromHex("CCCCCC")
+
+local BG_FADE_SPEED = 20 -- spring speed units
+
+local ROUNDED_CORNERS = UDim.new(0, 4)
+local PADDING = UDim2.fromOffset(6, 4)
+
+local function Button(
+ scope: Fusion.Scope,
+ props: {
+ Name: Fusion.CanBeState?,
+ Layout: {
+ LayoutOrder: Fusion.CanBeState?,
+ Position: Fusion.CanBeState?,
+ AnchorPoint: Fusion.CanBeState?,
+ ZIndex: Fusion.CanBeState?,
+ Size: Fusion.CanBeState?,
+ AutomaticSize: Fusion.CanBeState?
+ },
+ Text: Fusion.CanBeState?,
+ Disabled: Fusion.CanBeState?,
+ OnClick: (() -> ())?
+ }
+): Fusion.Child
+ local isHovering = scope:Value(false)
+ local isHeldDown = scope:Value(false)
+
+ return scope:New "TextButton" {
Name = props.Name,
- LayoutOrder = props.LayoutOrder,
- Position = props.Position,
- AnchorPoint = props.AnchorPoint,
- Size = props.Size,
- AutomaticSize = props.AutomaticSize,
- ZIndex = props.ZIndex,
+
+ LayoutOrder = props.Layout.LayoutOrder,
+ Position = props.Layout.Position,
+ AnchorPoint = props.Layout.AnchorPoint,
+ ZIndex = props.Layout.ZIndex,
+ Size = props.Layout.Size,
+ AutomaticSize = props.Layout.AutomaticSize,
Text = props.Text,
- TextColor3 = Color3.fromHex("FFFFFF"),
-
- BackgroundColor3 = Spring(Computed(function(use)
- if use(props.Disabled) then
- return Color3.fromHex("CCCCCC")
- else
- local baseColour = Color3.fromHex("0085FF")
- -- darken/lighten when hovered or held down
- if use(isHeldDown) then
- baseColour = baseColour:Lerp(Color3.new(0, 0, 0), 0.25)
- elseif use(isHovering) then
- baseColour = baseColour:Lerp(Color3.new(1, 1, 1), 0.25)
+ TextColor3 = COLOUR_TEXT,
+
+ BackgroundColor3 = scope:Spring(
+ scope:Computed(function(use)
+ -- The order of conditions matter here; it defines which states
+ -- visually override other states, with earlier states being
+ -- more important.
+ return
+ if use(props.Disabled) then COLOUR_BG_DISABLED
+ elseif use(isHeldDown) then COLOUR_BG_HELD
+ elseif use(isHovering) then COLOUR_BG_HOVER
+ else return COLOUR_BG_REST
end
- return baseColour
- end
- end), 20),
+ end),
+ BG_FADE_SPEED
+ ),
[OnEvent "Activated"] = function()
if props.OnClick ~= nil and not peek(props.Disabled) then
- -- We're explicitly calling this function with no arguments to
- -- match the types we specified above. If we just passed it
- -- straight into the event, the function would receive arguments
- -- from the Activated event, which might not be desirable.
+ -- Explicitly called with no arguments to match the typedef.
+ -- If passed straight to `OnEvent`, the function might receive
+ -- arguments from the event. If the function secretly *does*
+ -- take arguments (despite the type) this would cause problems.
props.OnClick()
end
end,
[OnEvent "MouseButton1Down"] = function()
- isHeldDown:set(true) -- it's good UX to give immediate feedback
+ isHeldDown:set(true)
end,
-
[OnEvent "MouseButton1Up"] = function()
isHeldDown:set(false)
end,
[OnEvent "MouseEnter"] = function()
-- Roblox calls this event even if the button is being covered by
- -- other UI. For simplicity, we won't worry about that.
+ -- other UI. For simplicity, this does not account for that.
isHovering:set(true)
end,
-
[OnEvent "MouseLeave"] = function()
- isHovering:set(false)
-- If the button is being held down, but the cursor moves off the
-- button, then we won't receive the mouse up event. To make sure
-- the button doesn't get stuck held down, we'll release it if the
-- cursor leaves the button.
isHeldDown:set(false)
+ isHovering:set(false)
end,
[Children] = {
New "UICorner" {
- CornerRadius = UDim.new(0, 4)
+ CornerRadius = ROUNDED_CORNERS
},
New "UIPadding" {
- PaddingTop = UDim.new(0, 6),
- PaddingBottom = UDim.new(0, 6),
- PaddingLeft = UDim.new(0, 6),
- PaddingRight = UDim.new(0, 6)
+ PaddingTop = PADDING.Y,
+ PaddingBottom = PADDING.Y,
+ PaddingLeft = PADDING.X,
+ PaddingRight = PADDING.X
}
}
}
end
return Button
-```
\ No newline at end of file
+```
+
+-----
+
+## Explanation
+
+The main part of note is the function signature. It's highly recommended that
+you statically type the function signature for components, because it not only
+improves autocomplete and error checking, but also acts as up-to-date, machine
+readable documentation.
+
+```Lua
+local function Button(
+ scope: Fusion.Scope,
+ props: {
+ Name: Fusion.CanBeState?,
+ Layout: {
+ LayoutOrder: Fusion.CanBeState?,
+ Position: Fusion.CanBeState?,
+ AnchorPoint: Fusion.CanBeState?,
+ ZIndex: Fusion.CanBeState?,
+ Size: Fusion.CanBeState?,
+ AutomaticSize: Fusion.CanBeState?
+ },
+ Text: Fusion.CanBeState?,
+ Disabled: Fusion.CanBeState?,
+ OnClick: (() -> ())?
+ }
+): Fusion.Child
+```
+
+The `scope` parameter specifies that the component depends on Fusion's methods.
+If you're not sure how to write type definitions for scopes,
+[the 'Scopes' section of the Components tutorial](../../../tutorials/components/components/#scopes)
+goes into further detail.
+
+The property table is laid out with each property on a new line, so it's easy to
+scan the list and see what properties are available. Most are `CanBeState`,
+which allows the user to use state objects if they desire. They're also `?`
+(optional), which can reduce boilerplate when using the component. Not all
+properties have to be that way, but usually it's better to have the flexibility.
+
+!!! tip "Property grouping"
+ You can group properties together in nested tables, like the `Layout` table
+ above, to avoid long mixed lists of properties. In addition to being more
+ readable, this can sometimes help with passing around lots of properties at
+ once, because you can pass the whole nested table as one value if you'd like
+ to.
+
+The return type of the function is `Fusion.Child`, which tells the user that the
+component is compatible with Fusion's `[Children]` API, without exposing what
+children it's returning specifically. This helps ensure the user doesn't
+accidentally depend on the internal structure of the component.
\ No newline at end of file
diff --git a/docs/examples/cookbook/drag-and-drop.md b/docs/examples/cookbook/drag-and-drop.md
index 2f4ede297..e274566c9 100644
--- a/docs/examples/cookbook/drag-and-drop.md
+++ b/docs/examples/cookbook/drag-and-drop.md
@@ -1,223 +1,152 @@
+This example shows a full drag-and-drop implementation for mouse input only,
+using Fusion's Roblox API.
+
+To ensure best accessibility, any interactions you implement shouldn't force you
+to hold the mouse button down. Either allow drag-and-drop with single clicks, or
+provide a non-dragging alternative. This ensures people with reduced motor
+ability aren't locked out of UI functions.
+
```Lua linenums="1"
-local GuiService = game:GetService("GuiService")
-local HttpService = game:GetService("HttpService")
+local Players = game:GetService("Players")
local UserInputService = game:GetService("UserInputService")
--- [Fusion imports omitted for clarity]
-
--- This example shows a full drag-and-drop implementation for mouse input only.
--- Extending this system to generically work with other input types, such as
--- touch gestures or gamepads, is left as an exercise to the reader. However, it
--- should robustly support dragging many types of UI around flexibly.
-
--- To ensure best accessibility, any interactions you implement shouldn't force
--- the player to hold the mouse button down. Either allow drag-and-drop using
--- single inputs, or provide a non-dragging alternative; this will ensure that
--- players with reduced motor ability aren't locked out of UI functions.
-
--- We're going to need to account for the UI inset sometimes. We cache it here.
-local TOP_LEFT_INSET = GuiService:GetGuiInset()
-
--- To reflect the current position of the cursor on-screen, we'll use a state
--- object that's updated using UserInputService.
-local mousePos = Value(UserInputService:GetMouseLocation() - TOP_LEFT_INSET)
-local mousePosConn = UserInputService.InputChanged:Connect(function(inputObject)
- if inputObject.UserInputType == Enum.UserInputType.MouseMovement then
- mousePos:set(Vector2.new(inputObject.Position.X, inputObject.Position.Y))
- end
-end)
-
--- We need to keep drag of which item is currently being dragged. Only one item
--- can be dragged at a time. This type stores all the information needed:
-export type CurrentlyDragging = {
- -- Each draggable item will have a unique ID; the ID stored here represents
- -- which item is being dragged right now. We'll use strings for this, but
- -- you could use numbers if that's more convenient for you.
- id: string,
- -- When a drag is started, we store the mouse's offset relative to the item
- -- being dragged. When the mouse moves, we can then apply the same offset to
- -- make it look like the item is 'pinned' to the cursor.
- offset: Vector2
-}
--- This state object stores the above during a drag, or `nil` when not dragging.
-local currentlyDragging: Value = Value(nil)
-
--- Now we need a component to encapsulate all of our dragging behaviour, such
--- as moving our UI between different parents, placing it at the mouse cursor,
--- managing sizing, and so on.
-
-export type DraggableProps = {
- -- This should uniquely identify the draggable item apart from all other
- -- draggable items. This is constant and so shouldn't be a state object.
- ID: string,
- -- It doesn't make sense for a draggable item to have a constant parent. You
- -- wouldn't be able to drop it anywhere else, so we enforce that Parent is a
- -- state object for our own convenience.
- Parent: StateObject,
- -- When an item is being dragged, it needs to appear above all other UI. We
- -- will create an overlay frame that fills the screen to achieve this.
- OverlayFrame: Instance,
- -- To start a drag, we'll need to know where the top-left corner of the item
- -- is, so we can calculate `currentlyDragging.offset`. We'll allow the
- -- calling code to pass through a Value object to [Out "AbsolutePosition"].
- OutAbsolutePosition: Value?,
-
- Name: CanBeState?,
- LayoutOrder: CanBeState?,
- Position: CanBeState?,
- AnchorPoint: CanBeState?,
- Size: CanBeState?,
- AutomaticSize: CanBeState?,
- ZIndex: CanBeState?,
- [Children]: Child
-}
-local function Draggable(props: DraggableProps): Child
- -- If we need something to be cleaned up when our item is destroyed, we can
- -- add it to this array. It'll be passed to `[Cleanup]` later.
- local cleanupTasks = {}
+local Fusion = -- initialise Fusion here however you please!
+local scoped = Fusion.scoped
+local Children, OnEvent = Fusion.Children, Fusion.OnEvent
- -- This acts like `currentlyDragging`, but filters out anything without a
- -- matching ID, so it'll only exist when this specific item is dragged.
- local thisDragging = Computed(function(use)
- local dragInfo = use(currentlyDragging)
- return if dragInfo ~= nil and dragInfo.id == props.ID then dragInfo else nil
- end)
+type DragInfo = {
+ id: string,
+ mouseOffset: Vector2 -- relative to the dragged item
+}
- -- Our item controls its own parent - one of the few times you'll see this
- -- done in Fusion. This means we don't have to destroy and re-build the item
- -- when it moves to a new location.
- local itemParent = Computed(function(use)
- return if use(thisDragging) ~= nil then props.OverlayFrame else use(props.Parent)
- end, Fusion.doNothing)
-
- -- If we move a scaled UI into the `overlayBox`, by default it will stretch
- -- to the screen size. Ideally we want it to preserve its current size while
- -- it's being dragged, so we need to track the parent's size and calculate
- -- the item size ourselves.
-
- -- To start with, we'll store the parent's absolute size. This takes a bit
- -- of legwork to get right, and we need to remember the UI might not have a
- -- parent which we can measure the size of - we'll represent that as `nil`.
- -- Feel free to extract this into a separate function if you want to.
- local parentSize = Value(nil)
+local function Draggable(
+ scope: Fusion.Scope,
+ props: {
+ ID: string,
+ Name: Fusion.CanBeState?,
+ Parent: Fusion.StateObject, -- StateObject so it's observable
+ Layout: {
+ LayoutOrder: Fusion.CanBeState?,
+ Position: Fusion.CanBeState?,
+ AnchorPoint: Fusion.CanBeState?,
+ ZIndex: Fusion.CanBeState?,
+ Size: Fusion.CanBeState?,
+ OutAbsolutePosition: Fusion.Value?,
+ },
+ Dragging: {
+ MousePosition: Fusion.CanBeState,
+ SelfDragInfo: Fusion.CanBeState,
+ OverlayFrame: Fusion.CanBeState
+ }
+ [typeof(Children)]: Fusion.Child
+ }
+): Fusion.Child
+ -- When `nil`, the parent can't be measured for some reason.
+ local parentSize = scope:Value(nil)
do
- -- We'll call this whenever the parent's AbsoluteSize changes, or when
- -- the parent changes (because different parents might have different
- -- absolute sizes, if any)
- local function recalculateParentSize()
+ local function measureParentNow()
local parent = peek(props.Parent)
- local parentHasSize = parent ~= nil and parent:IsA("GuiObject")
- parentSize:set(if parentHasSize then parent.AbsoluteSize else nil)
+ parentSize:set(
+ if parent ~= nil and parent:IsA("GuiObject")
+ then parent.AbsoluteSize
+ else nil
+ )
end
-
- -- We don't just need to connect to the AbsoluteSize changed event of
- -- the parent we have *right now*! If the parent changes, we need to
- -- disconnect the old event and re-connect on the new parent, which we
- -- do here.
- local parentSizeConn = nil
- local function rebindToParentSize()
- if parentSizeConn ~= nil then
- parentSizeConn:Disconnect()
- parentSizeConn = nil
+ local resizeConn = nil
+ local function stopMeasuring()
+ if resizeConn ~= nil then
+ resizeConn:Disconnect()
+ resizeConn = nil
end
- local parent = peek(props.Parent)
- local parentHasSize = parent ~= nil and parent:IsA("GuiObject")
- if parentHasSize then
- parentSizeConn = parent:GetPropertyChangedSignal("AbsoluteSize"):Connect(recalculateParentSize)
- end
- recalculateParentSize()
end
- local disconnect = Observer(props.Parent):onBind(rebindToParentSize)
-
- -- When the item gets destroyed, we need to disconnect that observer and
- -- our AbsoluteSize change event (if any is active right now)
- table.insert(cleanupTasks, function()
- disconnect()
- if parentSizeConn ~= nil then
- parentSizeConn:Disconnect()
- parentSizeConn = nil
+ scope:Observer(props.Parent):onBind(function()
+ stopMeasuring()
+ measureParentNow()
+ if peek(parentSize) ~= nil then
+ resizeConn = parent:GetPropertyChangedSignal("AbsoluteSize")
+ :Connect(measureParentNow)
end
end)
+ table.insert(scope, stopMeasuring)
end
- -- Now that we have a reliable parent size, we can calculate the item's size
- -- without worrying about all of those event connections.
- local itemSize = Computed(function(use)
- local udim2 = use(props.Size) or UDim2.fromOffset(0, 0)
- local scaleSize = use(parentSize) or Vector2.zero -- might be nil!
- return UDim2.fromOffset(
- udim2.X.Scale * scaleSize.X + udim2.X.Offset,
- udim2.Y.Scale * scaleSize.Y + udim2.Y.Offset
- )
- end)
-
- -- Similarly, we'll need to override the item's position while it's being
- -- dragged. Happily, this is simpler to do :)
- local itemPosition = Computed(function(use)
- local dragInfo = use(thisDragging)
- if dragInfo == nil then
- return use(props.Position) or UDim2.fromOffset(0, 0)
- else
- -- `dragInfo.offset` stores the distance from the top-left corner
- -- of the item to the mouse position. Subtracting the offset from
- -- the mouse position therefore gives us the item's position.
- local position = use(mousePos) - dragInfo.offset
- return UDim2.fromOffset(position.X, position.Y)
- end
- end)
-
return New "Frame" {
Name = props.Name or "Draggable",
- LayoutOrder = props.LayoutOrder,
- AnchorPoint = props.AnchorPoint,
- AutomaticSize = props.AutomaticSize,
- ZIndex = props.ZIndex,
-
- Parent = itemParent,
- Position = itemPosition,
- Size = itemSize,
+ Parent = scope:Computed(function(use)
+ return
+ if use(props.Dragging.SelfDragInfo) ~= nil
+ then use(props.Dragging.OverlayFrame)
+ else use(props.Parent)
+ end),
+
+ LayoutOrder = props.Layout.LayoutOrder,
+ AnchorPoint = props.Layout.AnchorPoint,
+ ZIndex = props.Layout.ZIndex,
+ AutomaticSize = props.Layout.AutomaticSize,
BackgroundTransparency = 1,
- [Out "AbsolutePosition"] = props.OutAbsolutePosition,
+ Position = scope:Computed(function(use)
+ local dragInfo = use(props.Dragging.SelfDragInfo)
+ if dragInfo == nil then
+ return use(props.Layout.Position) or UDim2.fromOffset(0, 0)
+ else
+ local mousePos = use(props.Dragging.MousePosition)
+ local topLeftCorner = mousePos - dragInfo.mouseOffset
+ return UDim2.fromOffset(topLeftCorner.X, topLeftCorner.Y)
+ end
+ end),
+ -- Calculated manually so the Scale can be set relative to
+ -- `props.Parent` at all times, rather than the `Parent` of this Frame.
+ Size = scope:Computed(function(use)
+ local udim2 = use(props.Layout.Size) or UDim2.fromOffset(0, 0)
+ local parentSize = use(parentSize) or Vector2.zero
+ return UDim2.fromOffset(
+ udim2.X.Scale * parentSize.X + udim2.X.Offset,
+ udim2.Y.Scale * parentSize.Y + udim2.Y.Offset
+ )
+ end),
+ [Out "AbsolutePosition"] = props.OutAbsolutePosition,
[Children] = props[Children]
}
end
--- The hard part is over! Now we just need to create some draggable items and
--- start/stop drags in response to mouse events. We'll use a very basic example.
+local COLOUR_COMPLETED = Color3.new(0, 1, 0)
+local COLOUR_NOT_COMPLETED = Color3.new(1, 1, 1)
+
+local TODO_ITEM_SIZE = UDim2.new(1, 0, 0, 50)
--- Let's make some to-do items. They'll show up in two lists - one for
--- incomplete tasks, and another for complete tasks. You'll be able to drag
--- items between the lists to mark them as complete. The lists will be sorted
--- alphabetically so we don't have to deal with calculating where the items
--- should be placed when they're dropped.
+local function newUniqueID()
+ -- You can replace this with a better method for generating unique IDs.
+ return game:GetService("HttpService"):GenerateGUID()
+end
-export type TodoItem = {
+type TodoItem = {
id: string,
text: string,
- completed: Value
+ completed: Fusion.Value
}
-local todoItems: Value = {
+local todoItems: Fusion.Value = {
{
- -- You can use HttpService to easily generate unique IDs statelessly.
- id = HttpService:GenerateGUID(),
+ id = newUniqueID(),
text = "Wake up today",
completed = Value(true)
},
{
- id = HttpService:GenerateGUID(),
+ id = newUniqueID(),
text = "Read the Fusion docs",
completed = Value(true)
},
{
- id = HttpService:GenerateGUID(),
+ id = newUniqueID(),
text = "Take over the universe",
completed = Value(false)
}
}
-local function getTodoItemForID(id: string): TodoItem?
+local function getTodoItemForID(
+ id: string
+): TodoItem?
for _, item in todoItems do
if item.id == id then
return item
@@ -226,192 +155,550 @@ local function getTodoItemForID(id: string): TodoItem?
return nil
end
--- These represent the individual draggable to-do item entries in the lists.
--- This is where we'll use our `Draggable` component!
-export type TodoEntryProps = {
- Item: TodoItem,
- Parent: StateObject,
- OverlayFrame: Instance,
-}
-local function TodoEntry(props: TodoEntryProps): Child
- local absolutePosition = Value(nil)
-
- -- Using our item's ID, we can figure out if we're being dragged to apply
- -- some styling for dragged items only!
- local isDragging = Computed(function(use)
- local dragInfo = use(currentlyDragging)
+local function TodoEntry(
+ outerScope: Fusion.Scope<{}>,
+ props: {
+ Item: TodoItem,
+ Parent: Fusion.StateObject,
+ Layout: {
+ LayoutOrder: Fusion.CanBeState?,
+ Position: Fusion.CanBeState?,
+ AnchorPoint: Fusion.CanBeState?,
+ ZIndex: Fusion.CanBeState?,
+ Size: Fusion.CanBeState?,
+ OutAbsolutePosition: Fusion.Value?,
+ },
+ Dragging: {
+ MousePosition: Fusion.CanBeState,
+ SelfDragInfo: Fusion.CanBeState,
+ OverlayFrame: Fusion.CanBeState?
+ },
+ OnMouseDown: () -> ()?
+ }
+): Fusion.Child
+ local scope = scoped(Fusion, {
+ Draggable = Draggable
+ })
+ table.insert(outerScope, scope)
+
+ local itemPosition = scope:Value(nil)
+ local itemIsDragging = scope:Computed(function(use)
+ local dragInfo = use(props.CurrentlyDragging)
return dragInfo ~= nil and dragInfo.id == props.Item.id
end)
- return Draggable {
+ return scope:Draggable {
ID = props.Item.id,
- Parent = props.Parent,
- OverlayFrame = props.OverlayFrame,
- OutAbsolutePosition = absolutePosition,
-
Name = props.Item.text,
- Size = UDim2.new(1, 0, 0, 50),
+ Parent = props.Parent,
+ Layout = props.Layout,
+ Dragging = props.Dragging,
- [Children] = New "TextButton" {
+ [Children] = scope:New "TextButton" {
Name = "TodoEntry",
Size = UDim2.fromScale(1, 1),
- BackgroundColor3 = Computed(function(use)
- if use(isDragging) then
- return Color3.new(1, 1, 1)
- elseif use(props.Item.completed) then
- return Color3.new(0, 1, 0)
- else
- return Color3.new(1, 0, 0)
+ BackgroundColor3 = scope:Computed(function(use)
+ return
+ if use(props.Item.completed)
+ then COLOUR_COMPLETED
+ else COLOUR_NOT_COMPLETED
end
end),
Text = props.Item.text,
TextSize = 28,
+
+ [OnEvent "MouseButton1Down"] = props.OnMouseDown
- -- This is where we'll detect mouse down. When the mouse is pressed
- -- over this item, we should pick it up.
- [OnEvent "MouseButton1Down"] = function()
- -- only start a drag if we're not already dragging
- if peek(currentlyDragging) == nil then
- local itemPos = peek(absolutePosition) or Vector2.zero
- local offset = peek(mousePos) - itemPos
- currentlyDragging:set({
- id = props.Item.id,
- offset = offset
- })
- end
- end
-
- -- We're not going to detect mouse up here, because in some rare
- -- cases the event could be dropped due to lag between the item's
- -- position and the cursor position. We'll deal with this at a
- -- global level instead.
+ -- Don't detect mouse up here, because in some rare cases, the event
+ -- could be missed due to lag between the item's position and the
+ -- cursor position.
}
}
end
--- Now we should construct our two task lists for housing our to-do entries.
--- Notice that they don't manage the entries themselves! The entries don't
--- belong to these lists after all, so that'd be nonsense :)
+-- Don't forget to pass this to `doCleanup` if you disable the script.
+local scope = scoped(Fusion)
--- When we release our mouse, we need to know where to drop any dragged item we
--- have. This will tell us if we're hovering over either list.
-local dropAction = Value(nil)
+local mousePos = scope:Value(UserInputService:GetMouseLocation())
+table.insert(scope,
+ UserInputService.InputChanged:Connect(function(inputObject)
+ if inputObject.UserInputType == Enum.UserInputType.MouseMovement then
+ -- If this code did not read coordinates from the same method, it
+ -- might inconsistently handle UI insets. So, keep it simple!
+ mousePos:set(UserInputService:GetMouseLocation())
+ end
+ end)
+)
-local incompleteList = New "ScrollingFrame" {
- Name = "IncompleteTasks",
- Position = UDim2.fromScale(0.1, 0.1),
- Size = UDim2.fromScale(0.35, 0.9),
+local dropAction = scope:Value(nil)
- BackgroundTransparency = 0.75,
- BackgroundColor3 = Color3.new(1, 0, 0),
+local taskLists = scope:ForPairs(
+ {
+ incomplete = "mark-as-incomplete",
+ completed = "mark-as-completed"
+ },
+ function(use, scope, listName, listDropAction)
+ return
+ listName,
+ scope:New "ScrollingFrame" {
+ Name = `TaskList ({listName})`,
+ Position = UDim2.fromScale(0.1, 0.1),
+ Size = UDim2.fromScale(0.35, 0.9),
+
+ BackgroundTransparency = 0.75,
+ BackgroundColor3 = Color3.new(1, 0, 0),
+
+ [OnEvent "MouseEnter"] = function()
+ dropAction:set(listDropAction)
+ end,
+
+ [OnEvent "MouseLeave"] = function()
+ -- A different item might have overwritten this already.
+ if peek(dropAction) == listDropAction then
+ dropAction:set(nil)
+ end
+ end,
+
+ [Children] = {
+ New "UIListLayout" {
+ SortOrder = "Name",
+ Padding = UDim.new(0, 5)
+ }
+ }
+ }
+ end
+)
+
+local overlayFrame = scope:New "Frame" {
+ Size = UDim2.fromScale(1, 1),
+ ZIndex = 10,
+ BackgroundTransparency = 1
+}
- [OnEvent "MouseEnter"] = function()
- dropAction:set("incomplete")
- end,
+local currentlyDragging: Fusion.Value = scope:Value(nil)
+
+local allEntries = scope:ForValues(
+ todoItems,
+ function(use, scope, item)
+ local itemPosition = scope:Value(nil)
+ return scope:TodoEntry {
+ Item = item,
+ Parent = scope:Computed(function(use)
+ return
+ if use(item.completed)
+ then use(taskLists).completed
+ else use(taskLists).incomplete
+ end),
+ Layout = {
+ Size = TODO_ITEM_SIZE,
+ OutAbsolutePosition = itemPosition
+ },
+ Dragging = {
+ MousePosition = mousePos,
+ SelfDragInfo = scope:Computed(function(use)
+ local dragInfo = use(currentlyDragging)
+ return
+ if dragInfo == nil or dragInfo.id ~= item.id
+ then nil
+ else dragInfo
+ end)
+ OverlayFrame = overlayFrame
+ },
+ OnMouseDown = function()
+ if peek(currentlyDragging) == nil then
+ local itemPos = peek(itemPosition) or Vector2.zero
+ local mouseOffset = peek(mousePos) - itemPos
+ currentlyDragging:set({
+ id = item.id,
+ mouseOffset = mouseOffset
+ })
+ end
+ end
+ }
+ end
+)
- [OnEvent "MouseLeave"] = function()
- if peek(dropAction) == "incomplete" then
- dropAction:set(nil) -- only clear this if it's not overwritten yet
+table.insert(scope,
+ UserInputService.InputEnded:Connect(function(inputObject)
+ if inputObject.UserInputType ~= Enum.UserInputType.MouseButton1 then
+ return
+ end
+ local dragInfo = peek(currentlyDragging)
+ if dragInfo == nil then
+ return
+ end
+ local item = getTodoItemForID(dragInfo.id)
+ local action = peek(dropAction)
+ if item ~= nil then
+ if action == "mark-as-incomplete" then
+ item.completed:set(false)
+ elseif action == "mark-as-completed" then
+ item.completed:set(true)
+ end
end
- end,
+ currentlyDragging:set(nil)
+ end)
+)
+
+local ui = scope:New "ScreenGui" {
+ Parent = Players.LocalPlayer:FindFirstChildOfClass("PlayerGui")
[Children] = {
- New "UIListLayout" {
- SortOrder = "Name",
- Padding = UDim.new(0, 5)
- }
+ overlayFrame,
+ taskLists,
+ -- Don't pass `allEntries` in here - they manage their own parent!
}
}
+```
-local completedList = New "ScrollingFrame" {
- Name = "CompletedTasks",
- Position = UDim2.fromScale(0.55, 0.1),
- Size = UDim2.fromScale(0.35, 0.9),
+------
- BackgroundTransparency = 0.75,
- BackgroundColor3 = Color3.new(0, 1, 0),
+## Explanation
- [OnEvent "MouseEnter"] = function()
- dropAction:set("completed")
- end,
+The basic idea is to create a container which stores the UI you want to drag.
+This container then reparents itself as it gets dragged around between
+different containers.
- [OnEvent "MouseLeave"] = function()
- if peek(dropAction) == "completed" then
- dropAction:set(nil) -- only clear this if it's not overwritten yet
- end
- end,
+The `Draggable` component implements everything necessary to make a seamlessly
+re-parentable container.
- [Children] = {
- New "UIListLayout" {
- SortOrder = "Name",
- Padding = UDim.new(0, 5)
+```Lua linenums="13"
+type DragInfo = {
+ id: string,
+ mouseOffset: Vector2 -- relative to the dragged item
+}
+
+local function Draggable(
+ scope: Fusion.Scope,
+ props: {
+ ID: string,
+ Name: Fusion.CanBeState?,
+ Parent: Fusion.StateObject, -- StateObject so it's observable
+ Layout: {
+ LayoutOrder: Fusion.CanBeState?,
+ Position: Fusion.CanBeState?,
+ AnchorPoint: Fusion.CanBeState?,
+ ZIndex: Fusion.CanBeState?,
+ Size: Fusion.CanBeState?,
+ OutAbsolutePosition: Fusion.Value?,
+ },
+ Dragging: {
+ MousePosition: Fusion.CanBeState,
+ SelfDragInfo: Fusion.CanBeState,
+ OverlayFrame: Fusion.CanBeState
}
+ [typeof(Children)]: Fusion.Child
+ }
+): Fusion.Child
+```
+
+By default, `Draggable` behaves like a regular Frame, parenting itself to the
+`Parent` property and applying its `Layout` properties.
+
+It only behaves specially when `Dragging.SelfDragInfo` is provided. Firstly,
+it reparents itself to `Dragging.OverlayFrame`, so it can be seen in front of
+other UI.
+
+```Lua linenums="66"
+ Parent = scope:Computed(function(use)
+ return
+ if use(props.Dragging.SelfDragInfo) ~= nil
+ then use(props.Dragging.OverlayFrame)
+ else use(props.Parent)
+ end),
+```
+
+Because of this reparenting, `Draggable` has to do some extra work to keep the
+size consistent; it manually calculates the size based on the size of `Parent`,
+so it doesn't change size when moved to `Dragging.OverlayFrame`.
+
+```Lua linenums="90"
+ -- Calculated manually so the Scale can be set relative to
+ -- `props.Parent` at all times, rather than the `Parent` of this Frame.
+ Size = scope:Computed(function(use)
+ local udim2 = use(props.Layout.Size) or UDim2.fromOffset(0, 0)
+ local parentSize = use(parentSize) or Vector2.zero
+ return UDim2.fromOffset(
+ udim2.X.Scale * parentSize.X + udim2.X.Offset,
+ udim2.Y.Scale * parentSize.Y + udim2.Y.Offset
+ )
+ end),
+```
+
+The `Draggable` also needs to snap to the mouse cursor, so it can be moved by
+the user. Ideally, the mouse would stay fixed in position relative to the
+`Draggable`, so there are no abrupt changes in the position of any elements.
+
+As part of `Dragging.SelfDragInfo`, a `mouseOffset` is provided, which describes
+how far the mouse should stay from the top-left corner. So, when setting the
+position of the `Draggable`, that offset can be applied to keep the UI fixed
+in position relative to the mouse.
+
+```Lua linenums="80"
+ Position = scope:Computed(function(use)
+ local dragInfo = use(props.Dragging.SelfDragInfo)
+ if dragInfo == nil then
+ return use(props.Layout.Position) or UDim2.fromOffset(0, 0)
+ else
+ local mousePos = use(props.Dragging.MousePosition)
+ local topLeftCorner = mousePos - dragInfo.mouseOffset
+ return UDim2.fromOffset(topLeftCorner.X, topLeftCorner.Y)
+ end
+ end),
+```
+
+This is all that's needed to make a generic container that can seamlessly move
+between distinct parts of the UI. The rest of the example demonstrates how this
+can be integrated into real world UI.
+
+The example creates a list of `TodoItem` objects, each with a unique ID, text
+message, and completion status. Because we don't expect the ID or text to
+change, they're just constant values. However, the completion status *is*
+expected to change, so that's specified to be a `Value` object.
+
+```Lua linenums="116"
+type TodoItem = {
+ id: string,
+ text: string,
+ completed: Fusion.Value
+}
+local todoItems: Fusion.Value = {
+ {
+ id = newUniqueID(),
+ text = "Wake up today",
+ completed = Value(true)
+ },
+ {
+ id = newUniqueID(),
+ text = "Read the Fusion docs",
+ completed = Value(true)
+ },
+ {
+ id = newUniqueID(),
+ text = "Take over the universe",
+ completed = Value(false)
}
}
+```
+
+The `TodoEntry` component is meant to represent one individual `TodoItem`.
+
+```Lua linenums="149"
+local function TodoEntry(
+ outerScope: Fusion.Scope<{}>,
+ props: {
+ Item: TodoItem,
+ Parent: Fusion.StateObject,
+ Layout: {
+ LayoutOrder: Fusion.CanBeState?,
+ Position: Fusion.CanBeState?,
+ AnchorPoint: Fusion.CanBeState?,
+ ZIndex: Fusion.CanBeState?,
+ Size: Fusion.CanBeState?,
+ OutAbsolutePosition: Fusion.Value?,
+ },
+ Dragging: {
+ MousePosition: Fusion.CanBeState,
+ SelfDragInfo: Fusion.CanBeState,
+ OverlayFrame: Fusion.CanBeState?
+ },
+ OnMouseDown: () -> ()?
+ }
+): Fusion.Child
+```
--- Now we can write a mouse up handler to drop our items.
+Notice that it shares many of the same property groups as `Draggable` - these
+can be passed directly through.
-local mouseUpConn = UserInputService.InputEnded:Connect(function(inputObject)
- if inputObject.UserInputType ~= Enum.UserInputType.MouseButton1 then
- return
- end
- local dragInfo = peek(currentlyDragging)
- if dragInfo == nil then
- return
- end
- local item = getTodoItemForID(dragInfo.id)
- local action = peek(dropAction)
- if item ~= nil then
- if action == "incomplete" then
- item.completed:set(false)
- elseif action == "completed" then
- item.completed:set(true)
- end
+```Lua linenums="181"
+ return scope:Draggable {
+ ID = props.Item.id,
+ Name = props.Item.text,
+ Parent = props.Parent,
+ Layout = props.Layout,
+ Dragging = props.Dragging,
+```
+
+It also provides an `OnMouseDown` callback, which can be used to pick up the
+entry if the mouse is pressed down above the entry. Note the comment about why
+it is *not* desirable to detect mouse-up here; the UI should unconditionally
+respond to mouse-up, even if the mouse happens to briefly leave this element.
+
+```Lua linenums="202"
+ [OnEvent "MouseButton1Down"] = props.OnMouseDown
+
+ -- Don't detect mouse up here, because in some rare cases, the event
+ -- could be missed due to lag between the item's position and the
+ -- cursor position.
+```
+
+Now, the destinations for these entries can be created. To help decide where to
+drop items later, the `dropAction` tracks which destination the mouse is hovered
+over.
+
+```Lua linenums="225"
+local dropAction = scope:Value(nil)
+
+local taskLists = scope:ForPairs(
+ {
+ incomplete = "mark-as-incomplete",
+ completed = "mark-as-completed"
+ },
+ function(use, scope, listName, listDropAction)
+ return
+ listName,
+ scope:New "ScrollingFrame" {
+ Name = `TaskList ({listName})`,
+ Position = UDim2.fromScale(0.1, 0.1),
+ Size = UDim2.fromScale(0.35, 0.9),
+
+ BackgroundTransparency = 0.75,
+ BackgroundColor3 = Color3.new(1, 0, 0),
+
+ [OnEvent "MouseEnter"] = function()
+ dropAction:set(listDropAction)
+ end,
+
+ [OnEvent "MouseLeave"] = function()
+ -- A different item might have overwritten this already.
+ if peek(dropAction) == listDropAction then
+ dropAction:set(nil)
+ end
+ end,
+
+ [Children] = {
+ New "UIListLayout" {
+ SortOrder = "Name",
+ Padding = UDim.new(0, 5)
+ }
+ }
+ }
end
- currentlyDragging:set(nil)
-end)
+)
+```
--- We'll need to construct an overlay frame for our items to live in while they
--- get dragged around.
+This is also where the 'overlay frame' is created, which gives currently-dragged
+UI a dedicated layer above all other UI to freely move around.
-local overlayFrame = New "Frame" {
+```Lua linenums="264"
+local overlayFrame = scope:New "Frame" {
Size = UDim2.fromScale(1, 1),
ZIndex = 10,
BackgroundTransparency = 1
}
+```
+
+Finally, each `TodoItem` is created as a `TodoEntry`. Some state is also created
+to track which entry is being dragged at the moment.
+
+```Lua linenums="270"
+local currentlyDragging: Fusion.Value = scope:Value(nil)
+
+local allEntries = scope:ForValues(
+ todoItems,
+ function(use, scope, item)
+ local itemPosition = scope:Value(nil)
+ return scope:TodoEntry {
+ Item = item,
+```
+
+Each entry dynamically picks one of the two destinations based on its
+completion status.
+
+```Lua linenums="278"
+ Parent = scope:Computed(function(use)
+ return
+ if use(item.completed)
+ then use(taskLists).completed
+ else use(taskLists).incomplete
+ end),
+```
+
+It also provides the information needed by the `Draggable`.
+
+Note that the current drag information is filtered from the `currentlyDragging`
+state so the `Draggable` won't see information about other entries being
+dragged.
+
+```Lua linenums="288"
+ Dragging = {
+ MousePosition = mousePos,
+ SelfDragInfo = scope:Computed(function(use)
+ local dragInfo = use(currentlyDragging)
+ return
+ if dragInfo == nil or dragInfo.id ~= item.id
+ then nil
+ else dragInfo
+ end)
+ OverlayFrame = overlayFrame
+ },
+```
+
+Now it's time to handle starting and stopping the drag.
+
+To begin the drag, this code makes use of the `OnMouseDown` callback. If nothing
+else is being dragged right now, the position of the mouse relative to the item
+is captured. Then, that `mouseOffset` and the `id` of the item are passed into
+the `currentlyDragging` state to indicate this entry is being dragged.
+
+```Lua linenums="299"
+ OnMouseDown = function()
+ if peek(currentlyDragging) == nil then
+ local itemPos = peek(itemPosition) or Vector2.zero
+ local mouseOffset = peek(mousePos) - itemPos
+ currentlyDragging:set({
+ id = item.id,
+ mouseOffset = mouseOffset
+ })
+ end
+ end
+```
--- Let's construct the items themselves! Because we're constructing them at the
--- global level like this, they're only created and destroyed when they're added
--- and removed from the list.
-
-local allEntries = ForValues(todoItems, function(use, item)
- return TodoEntry {
- Item = item,
- Parent = Computed(function()
- return if use(item.completed) then completedList else incompleteList
- end, Fusion.doNothing),
- OverlayFrame = overlayFrame
- }
-end, Fusion.cleanup)
+To end the drag, a global `InputEnded` listener is created, which should
+reliably fire no matter where or when the event occurs.
--- Finally, construct the whole UI :)
+If there's a `dropAction` to take, for example `mark-as-completed`, then that
+action is executed here.
-local ui = New "ScreenGui" {
- Parent = game:GetService("Players").LocalPlayer.PlayerGui,
+In all cases, `currentlyDragging` is cleared, so the entry is no longer dragged.
- [Cleanup] = {
- mousePosConn,
- mouseUpConn
- },
+```Lua linenums="313"
+table.insert(scope,
+ UserInputService.InputEnded:Connect(function(inputObject)
+ if inputObject.UserInputType ~= Enum.UserInputType.MouseButton1 then
+ return
+ end
+ local dragInfo = peek(currentlyDragging)
+ if dragInfo == nil then
+ return
+ end
+ local item = getTodoItemForID(dragInfo.id)
+ local action = peek(dropAction)
+ if item ~= nil then
+ if action == "mark-as-incomplete" then
+ item.completed:set(false)
+ elseif action == "mark-as-completed" then
+ item.completed:set(true)
+ end
+ end
+ currentlyDragging:set(nil)
+ end)
+)
+```
+
+All that remains is to parent the task lists and overlay frames to a UI, so they
+can be seen. Because the `TodoEntry` component manages their own parent, this
+code shouldn't pass in `allEntries` as a child here.
+
+```Lua linenums="335"
+local ui = scope:New "ScreenGui" {
+ Parent = Players.LocalPlayer:FindFirstChildOfClass("PlayerGui")
[Children] = {
overlayFrame,
- incompleteList,
- completedList
-
- -- We don't have to pass `allEntries` in here - they manage their own
- -- parenting thanks to `Draggable` :)
+ taskLists,
+ -- Don't pass `allEntries` in here - they manage their own parent!
}
}
```
\ No newline at end of file
diff --git a/docs/examples/cookbook/fetch-data-from-server.md b/docs/examples/cookbook/fetch-data-from-server.md
index 86d74e483..9506482a5 100644
--- a/docs/examples/cookbook/fetch-data-from-server.md
+++ b/docs/examples/cookbook/fetch-data-from-server.md
@@ -1,56 +1,149 @@
+This code shows how to deal with yielding/blocking code, such as fetching data
+from a server.
+
+Because these tasks don't complete immediately, they can't be directly run
+inside of a `Computed`, so this example provides a robust framework for handling
+this in a way that doesn't corrupt your code.
+
+This example assumes the presence of a Roblox-like `task` scheduler.
+
+-----
+
+## Overview
+
```Lua linenums="1"
--- [Fusion imports omitted for clarity]
+local Fusion = -- initialise Fusion here however you please!
+local scoped = Fusion.scoped
+
+local function fetchUserBio(
+ userID: number
+): string
+ -- pretend this calls out to a server somewhere, causing this code to yield
+ task.wait(1)
+ return "This is the bio for user " .. userID .. "!"
+end
--- This code assumes that there is a RemoteFunction at this location, which
--- accepts a user ID and will return a string with that user's bio text.
--- The server implementation is not shown here.
-local FetchUserBio = game:GetService("ReplicatedStorage").FetchUserBio
+-- Don't forget to pass this to `doCleanup` if you disable the script.
+local scope = scoped(Fusion)
--- Creating a Value object to store the user ID we're currently looking at
-local currentUserID = Value(1670764)
+-- This doesn't have to be a `Value` - any kind of state object works too.
+local currentUserID = scope:Value(1670764)
--- If we could instantly calculate the user's bio text, we could use a Computed
--- here. However, fetching data from the server takes time, which means we can't
--- use Computed without introducing serious consistency errors into our program.
+-- While the bio is loading, this is `nil` instead of a string.
+local currentUserBio: Fusion.Value = scope:Value(nil)
--- Instead, we fall back to using an observer to manually manage our own value:
-local currentUserBio = Value(nil)
--- Using a scope to hide our management code from the rest of the script:
do
- local lastFetchTime = nil
- local function fetch()
- local fetchTime = os.clock()
- lastFetchTime = fetchTime
- currentUserBio:set(nil) -- set to a default value to indicate loading
- task.spawn(function()
- local bio = FetchUserBio:InvokeServer(peek(currentUserID))
- -- If these two are not equal, then that means another fetch was
- -- started while we were waiting for the server to return a value.
- -- In that case, the more recent call will be more up-to-date, so we
- -- shouldn't overwrite it. This adds a nice layer of reassurance,
- -- but if your value doesn't change often, this might be optional.
- if lastFetchTime == fetchTime then
- currentUserBio:set(bio)
- end
+ local fetchInProgress = nil
+ local function performFetch()
+ local userID = peek(currentUserID)
+ currentUserBio:set(nil)
+ if fetchInProgress ~= nil then
+ task.cancel(fetchInProgress)
+ end
+ fetchInProgress = task.spawn(function()
+ currentUserBio:set(fetchUserBio())
+ fetchInProgress = nil
end)
end
+ scope:Observer(currentUserID):onBind(performFetch)
+end
+
+scope:Observer(currentUserBio):onBind(function()
+ local bio = peek(currentUserBio)
+ if bio == nil then
+ print("User bio is loading...")
+ else
+ print("Loaded user bio:", bio)
+ end
+end)
+```
+
+-----
- fetch() -- get the bio for the initial user ID
- -- when the user ID changes, reload the bio
- local disconnect = Observer(currentUserID):onChange(fetch)
+## Explanation
- -- Don't forget to call `disconnect` when you're done with `currentUserBio`.
- -- That's not included in this code snippet, but it's important if you want
- -- to avoid leaking memory.
+If you yield or wait inside of a `Computed`, you can easily corrupt your entire
+program.
+
+However, this example has a function, `fetchUserBio`, that yields.
+
+```Lua linenums="5"
+local function fetchUserBio(
+ userID: number
+): string
+ -- pretend this calls out to a server somewhere, causing this code to yield
+ task.wait(1)
+ return "This is the bio for user " .. userID .. "!"
end
+```
+
+It also has some arbitrary state object, `currentUserID`, that it needs to
+convert into a bio somehow.
--- Now, you can use `currentUserBio` just like any other state object! Note that
--- `nil` is used to represent a bio that hasn't loaded yet, so you'll want to
--- handle that case before passing it into any code that expects a solid value.
+```Lua linenums="15"
+-- This doesn't have to be a `Value` - any kind of state object works too.
+local currentUserID = scope:Value(1670764)
+```
+
+Because `Computed` can't yield, this code has to manually manage a
+`currentUserBio` object, which will store the output of the code in a way that
+can be used by other Fusion objects later.
+
+Notice that the 'loading' state is explicitly documented. It's a good idea to
+be clear and honest when you have no data to show, because it allows other code
+to respond to that case flexibly.
+
+```Lua linenums="18"
+-- While the bio is loading, this is `nil` instead of a string.
+local currentUserBio: Fusion.Value = scope:Value(nil)
+```
+
+To perform the actual fetch, a simple function can be written which calls
+`fetchUserBio` in a separate task. Once it returns a bio, the `currentUserBio`
+can be updated.
+
+To avoid two fetches overwriting each other, any existing fetch task is canceled
+before the new task is created.
+
+```Lua linenums="22"
+ local fetchInProgress = nil
+ local function performFetch()
+ local userID = peek(currentUserID)
+ currentUserBio:set(nil)
+ if fetchInProgress ~= nil then
+ task.cancel(fetchInProgress)
+ end
+ fetchInProgress = task.spawn(function()
+ currentUserBio:set(fetchUserBio())
+ fetchInProgress = nil
+ end)
+ end
+```
+
+Finally, to run this function when the `currentUserID` changes, `performFetch`
+can be added to an `Observer`.
+
+The `onBind` method also runs `performFetch` once at the start of the program,
+so the request is sent out automatically.
+
+```Lua linenums="34"
+scope:Observer(currentUserID):onBind(performFetch)
+```
+
+That's all you need - now, any other Fusion code can read and depend upon
+`currentUserBio` as if it were any other kind of state object. Just remember to
+handle the 'loading' state as well as the successful state.
+
+```Lua linenums="37"
+scope:Observer(currentUserBio):onBind(function()
+ local bio = peek(currentUserBio)
+ if bio == nil then
+ print("User bio is loading...")
+ else
+ print("Loaded user bio:", bio)
+ end
+end)
+```
-local bioLabel = New "TextLabel" {
- Text = Computed(function(use)
- return use(currentUserBio) or "Loading user bio..."
- end)
-}
-```
\ No newline at end of file
+You may wish to expand this code with error handling if `fetchUserBio()` can
+throw errors.
\ No newline at end of file
diff --git a/docs/examples/cookbook/light-and-dark-theme.md b/docs/examples/cookbook/light-and-dark-theme.md
index a9ffb1b27..3aec057e7 100644
--- a/docs/examples/cookbook/light-and-dark-theme.md
+++ b/docs/examples/cookbook/light-and-dark-theme.md
@@ -1,42 +1,88 @@
+This example demonstrates how to create dynamic theme colours using Fusion's
+state objects.
+
+-----
+
+## Overview
+
```Lua linenums="1"
--- [Fusion imports omitted for clarity]
+local Fusion = --initialise Fusion here however you please!
+local scoped = Fusion.scoped
--- Defining some theme colours. Something to note; I'm intentionally putting the
--- actual colour names as the topmost keys here, and putting `light` and `dark`
--- keys inside the colours. If you did it the other way around, then there's no
--- single source of truth for what colour names are available, and it's hard to
--- keep in sync. If a theme doesn't have a colour, it's better to explicitly not
--- specify it under the colour name.
+local Theme = {}
-local THEME_COLOURS = {
+Theme.colours = {
background = {
light = Color3.fromHex("FFFFFF"),
- dark = Color3.fromHex("2D2D2D")
+ dark = Color3.fromHex("222222")
},
text = {
- light = Color3.fromHex("2D2D2D"),
+ light = Color3.fromHex("222222"),
dark = Color3.fromHex("FFFFFF")
- },
- -- [etc, for all the colours you'd want]
+ }
}
--- This will control which colours we're using at the moment. You could expose
--- this to the rest of your code directly, or calculate it using a Computed.
-local currentTheme = Value("light")
+-- Don't forget to pass this to `doCleanup` if you disable the script.
+local scope = scoped(Fusion)
--- Now we'll create a Computed for every theme colour, which will pick a colour
--- from `THEME_COLS` based on our `currentTheme`.
-local currentColours = {}
-for colourName, colourOptions in THEME_COLOURS do
- currentColours[colourName] = Computed(function(use)
- return colourOptions[use(currentTheme)]
+Theme.current = scope:Value("light")
+Theme.dynamic = {}
+for colour, variants in Theme.colours do
+ Theme.dynamic[colour] = scope:Computed(function(use)
+ return variants[use(Theme.current)]
end)
end
--- Now you can expose `colourOptions` to the rest of your code, preferably under
--- a convenient name :)
+Theme.current:set("light")
+print(peek(Theme.dynamic.background)) --> 255, 255, 255
+
+Theme.current:set("dark")
+print(peek(Theme.dynamic.background)) --> 34, 34, 34
+```
+
+-----
-local text = New "TextLabel" {
- TextColor3 = currentColours.text
+## Explanation
+
+To begin, this example defines a set of colours with light and dark variants.
+
+```Lua linenums="6"
+Theme.colours = {
+ background = {
+ light = Color3.fromHex("FFFFFF"),
+ dark = Color3.fromHex("222222")
+ },
+ text = {
+ light = Color3.fromHex("222222"),
+ dark = Color3.fromHex("FFFFFF")
+ }
}
+```
+
+A `Value` object stores which variant is in use right now.
+
+```Lua linenums="20"
+Theme.current = scope:Value("light")
+```
+
+Finally, each colour is turned into a `Computed`, which dynamically pulls the
+desired variant from the list.
+
+```Lua linenums="21"
+Theme.dynamic = {}
+for colour, variants in Theme.colours do
+ Theme.dynamic[colour] = scope:Computed(function(use)
+ return variants[use(Theme.current)]
+ end)
+end
+```
+
+This allows other code to easily access theme colours from `Theme.dynamic`.
+
+```Lua linenums="28"
+Theme.current:set("light")
+print(peek(Theme.dynamic.background)) --> 255, 255, 255
+
+Theme.current:set("dark")
+print(peek(Theme.dynamic.background)) --> 34, 34, 34
```
\ No newline at end of file
diff --git a/docs/examples/cookbook/loading-spinner.md b/docs/examples/cookbook/loading-spinner.md
index 70e71017b..c944684f2 100644
--- a/docs/examples/cookbook/loading-spinner.md
+++ b/docs/examples/cookbook/loading-spinner.md
@@ -1,39 +1,128 @@
+This example implements a procedural spinning animation using Fusion's Roblox
+APIs.
+
+-----
+
+## Overview
+
```Lua linenums="1"
local RunService = game:GetService("RunService")
--- [Fusion imports omitted for clarity]
-
--- Loading spinners generally don't use transition-based animations like tweens.
--- Instead, they animate continuously and independently, so we'll need to set up
--- our own animation clock that will drive the animation.
--- We can set up one clock and use it everywhere.
-local timer = Value(os.clock())
-local timerConn = RunService.RenderStepped:Connect(function()
- -- Remember to disconnect this event when you're done using it!
- timer:set(os.clock())
-end)
-
--- Our loading spinner will consist of an image which rotates around. You could
--- do something more complex or intricate for spice, but in the interest of
--- providing a simple starting point, let's keep it simple.
-local spinner = New "ImageLabel" {
- Position = UDim2.fromScale(0.5, 0.5),
- AnchorPoint = Vector2.new(0.5, 0.5),
- Size = UDim2.fromOffset(50, 50),
-
- BackgroundTransparency = 1,
- Image = "rbxassetid://your-loading-spinner-image", -- replace this!
-
- -- As the timer runs, this will automatically update and rotate our image.
- Rotation = Computed(function(use)
- local time = use(timer)
- local angle = time * 180 -- Spin at a rate of 180 degrees per second
- angle %= 360 -- Don't need to go beyond 360 degrees; wrap instead
- return angle
- end),
-
- -- If your `timer` is only used by this one loading spinner, you can clean
- -- up the `timerConn` here. If you're re-using one timer for all of your
- -- spinners, you don't need to do this here.
- [Cleanup] = timerConn
+
+local Fusion = -- initialise Fusion here however you please!
+local scoped = Fusion.scoped
+local Children = Fusion.Children
+
+local SPIN_DEGREES_PER_SECOND = 180
+local SPIN_SIZE = 50
+
+local function Spinner(
+ scope: Fusion.Scope,
+ props: {
+ Layout: {
+ LayoutOrder: Fusion.CanBeState?,
+ Position: Fusion.CanBeState?,
+ AnchorPoint: Fusion.CanBeState?,
+ ZIndex: Fusion.CanBeState?
+ },
+ CurrentTime: Fusion.CanBeState,
+ }
+): Fusion.Child
+ return scope:New "ImageLabel" {
+ Name = "Spinner",
+
+ LayoutOrder = props.Layout.LayoutOrder,
+ Position = props.Layout.Position,
+ AnchorPoint = props.Layout.AnchorPoint,
+ ZIndex = props.Layout.ZIndex,
+
+ Size = UDim2.fromOffset(SPIN_SIZE, SPIN_SIZE),
+
+ BackgroundTransparency = 1,
+ Image = "rbxassetid://your-loading-spinner-image", -- replace this!
+
+ Rotation = scope:Computed(function(use)
+ return (use(props.CurrentTime) * SPIN_DEGREES_PER_SECOND) % 360
+ end)
+ }
+end
+
+-- Don't forget to pass this to `doCleanup` if you disable the script.
+local scope = scoped(Fusion, {
+ Spinner = Spinner
+})
+
+local currentTime = scope:Value(os.clock())
+table.insert(scope,
+ RunService.RenderStepped:Connect(function()
+ currentTime:set(os.clock())
+ end)
+)
+
+local spinner = scope:Spinner {
+ Layout = {
+ Position = UDim2.fromScale(0.5, 0.5),
+ AnchorPoint = Vector2.new(0.5, 0.5),
+ Size = UDim2.fromOffset(50, 50)
+ },
+ CurrentTime = currentTime
+}
+```
+
+-----
+
+## Explanation
+
+The `Spinner` components implements the animation for the loading spinner. It's
+largely a standard Fusion component definition.
+
+The main thing to note is that it asks for a `CurrentTime` property.
+
+```Lua linenums="10" hl_lines="10"
+local function Spinner(
+ scope: Fusion.Scope,
+ props: {
+ Layout: {
+ LayoutOrder: Fusion.CanBeState?,
+ Position: Fusion.CanBeState?,
+ AnchorPoint: Fusion.CanBeState?,
+ ZIndex: Fusion.CanBeState?
+ },
+ CurrentTime: Fusion.CanBeState,
+ }
+): Fusion.Child
+```
+
+The `CurrentTime` is used to drive the rotation of the loading spinner.
+
+```Lua linenums="35"
+ Rotation = scope:Computed(function(use)
+ return (use(props.CurrentTime) * SPIN_DEGREES_PER_SECOND) % 360
+ end)
+```
+
+That's all that's required for the `Spinner` component.
+
+Later on, the example creates a `Value` object that will store the current time,
+and starts a process to keep it up to date.
+
+```Lua linenums="46"
+local currentTime = scope:Value(os.clock())
+table.insert(scope,
+ RunService.RenderStepped:Connect(function()
+ currentTime:set(os.clock())
+ end)
+)
+```
+
+This can then be passed in as `CurrentTime` when the `Spinner` is created.
+
+```Lua linenums="53" hl_lines="7"
+local spinner = scope:Spinner {
+ Layout = {
+ Position = UDim2.fromScale(0.5, 0.5),
+ AnchorPoint = Vector2.new(0.5, 0.5),
+ Size = UDim2.fromOffset(50, 50)
+ },
+ CurrentTime = currentTime
}
```
\ No newline at end of file
diff --git a/docs/examples/cookbook/player-list.md b/docs/examples/cookbook/player-list.md
index dd169286e..4d4a34102 100644
--- a/docs/examples/cookbook/player-list.md
+++ b/docs/examples/cookbook/player-list.md
@@ -1,47 +1,24 @@
-```Lua linenums="1"
--- [Fusion imports omitted for clarity]
-
-type Set = {[T]: true}
+This shows how to use Fusion's Roblox API to create a simple, dynamically
+updating player list.
--- Defining a component for each row of the player list.
--- Each row represents a player currently logged into the server.
--- We set the `Name` to the player's name so the rows can be sorted by name.
+-----
-type PlayerListRowProps = {
- Player: Player
-}
+## Overview
-local function PlayerListRow(props: PlayerListRowProps)
- return New "TextLabel" {
- Name = props.Player.DisplayName,
-
- Size = UDim2.new(1, 0, 0, 25),
- BackgroundTransparency = 1,
+```Lua linenums="1"
+local Players = game:GetService("Players")
- Text = props.Player.DisplayName,
- TextColor3 = Color3.new(1, 1, 1),
- Font = Enum.Font.GothamMedium,
- FontSize = 16,
- TextXAlignment = "Right",
- TextTruncate = "AtEnd",
+local Fusion = -- initialise Fusion here however you please!
+local scoped = Fusion.scoped
+local Children = Fusion.Children
- [Children] = New "UIPadding" {
- PaddingLeft = UDim.new(0, 10),
- PaddingRight = UDim.new(0, 10)
- }
+local function PlayerList(
+ scope: Fusion.Scope,
+ props: {
+ Players: Fusion.CanBeState<{Player}>
}
-end
-
--- Defining a component for the entire player list.
--- It should take in a set of all logged-in players, and it should be a state
--- object so the set of players can change as players join and leave.
-
-type PlayerListProps = {
- PlayerSet: Fusion.StateObject>
-}
-
-local function PlayerList(props: PlayerListProps)
- return New "Frame" {
+): Fusion.Child
+ return scope:New "Frame" {
Name = "PlayerList",
Position = UDim2.fromScale(1, 0),
@@ -53,53 +30,131 @@ local function PlayerList(props: PlayerListProps)
BackgroundColor3 = Color3.new(0, 0, 0),
[Children] = {
- New "UICorner" {},
- New "UIListLayout" {
+ scope:New "UICorner" {
+ CornerRadius = UDim.new(0, 8)
+ },
+ scope:New "UIListLayout" {
SortOrder = "Name",
FillDirection = "Vertical"
},
- ForPairs(props.PlayerSet, function(use, player, _)
- return player, PlayerListRow {
- Player = player
+ scope:ForValues(props.Players, function(use, scope, player)
+ return scope:New "TextLabel" {
+ Name = "PlayerListRow: " .. player.DisplayName,
+
+ Size = UDim2.new(1, 0, 0, 25),
+ BackgroundTransparency = 1,
+
+ Text = player.DisplayName,
+ TextColor3 = Color3.new(1, 1, 1),
+ Font = Enum.Font.GothamMedium,
+ FontSize = 16,
+ TextXAlignment = "Right",
+ TextTruncate = "AtEnd",
+
+ [Children] = scope:New "UIPadding" {
+ PaddingLeft = UDim.new(0, 10),
+ PaddingRight = UDim.new(0, 10)
+ }
}
- end, Fusion.cleanup)
+ end)
}
}
end
--- To create the PlayerList component, first we need a state object that stores
--- the set of logged-in players, and updates as players join and leave.
-
-local Players = game:GetService("Players")
+-- Don't forget to pass this to `doCleanup` if you disable the script.
+local scope = scoped(Fusion, {
+ PlayerList = PlayerList
+})
-local playerSet = Value()
-local function updatePlayerSet()
- local newPlayerSet = {}
- for _, player in Players:GetPlayers() do
- newPlayerSet[player] = true
- end
- playerSet:set(newPlayerSet)
+local players = scope:Value(Players:GetPlayers())
+local function updatePlayers()
+ players:set(Players:GetPlayers())
end
-local playerConnections = {
- Players.PlayerAdded:Connect(updatePlayerSet),
- Players.PlayerRemoving:Connect(updatePlayerSet)
+table.insert(scope, {
+ Players.PlayerAdded:Connect(updatePlayers),
+ Players.PlayerRemoving:Connect(updatePlayers)
+})
+
+local gui = scope:New "ScreenGui" {
+ Name = "PlayerListGui",
+ Parent = Players.LocalPlayer:FindFirstChildOfClass("PlayerGui"),
+
+ [Children] = scope:PlayerList {
+ Players = players
+ }
}
-updatePlayerSet()
+```
--- Now, we can create the component and pass in `playerSet`.
--- Don't forget to clean up your connections when your UI is destroyed; to do
--- that, we're using the `[Cleanup]` key to clean up `playerConnections` later.
+-----
-local gui = New "ScreenGui" {
+## Explanation
+
+The `PlayerList` component is designed to be simple and self-contained. The only
+thing it needs is a `Players` list - it handles everything else, including its
+position, size, appearance and behaviour.
+
+```Lua linenums="7"
+local function PlayerList(
+ scope: Fusion.Scope,
+ props: {
+ Players: Fusion.CanBeState<{Player}>
+ }
+): Fusion.Child
+```
+
+After creating a vertically expanding Frame with some style and layout added,
+it turns the `Players` into a series of text labels using `ForValues`, which
+will automatically create and remove them as the `Players` list changes.
+
+```Lua linenums="33"
+ scope:ForValues(props.Players, function(use, scope, player)
+ return scope:New "TextLabel" {
+ Name = "PlayerListRow: " .. player.DisplayName,
+
+ Size = UDim2.new(1, 0, 0, 25),
+ BackgroundTransparency = 1,
+
+ Text = player.DisplayName,
+ TextColor3 = Color3.new(1, 1, 1),
+ Font = Enum.Font.GothamMedium,
+ FontSize = 16,
+ TextXAlignment = "Right",
+ TextTruncate = "AtEnd",
+
+ [Children] = scope:New "UIPadding" {
+ PaddingLeft = UDim.new(0, 10),
+ PaddingRight = UDim.new(0, 10)
+ }
+ }
+ end)
+```
+
+That's all that the `PlayerList` component has to do.
+
+Later on, the code creates a `Value` object to store a list of players, and
+update it every time a player joins or leaves the game.
+
+```Lua linenums="62"
+local players = scope:Value(Players:GetPlayers())
+local function updatePlayers()
+ players:set(Players:GetPlayers())
+end
+table.insert(scope, {
+ Players.PlayerAdded:Connect(updatePlayers),
+ Players.PlayerRemoving:Connect(updatePlayers)
+})
+```
+
+That object can then be passed in as `Players` when creating the `PlayerList`.
+
+```Lua linenums="71" hl_lines="6"
+local gui = scope:New "ScreenGui" {
Name = "PlayerListGui",
Parent = Players.LocalPlayer:FindFirstChildOfClass("PlayerGui"),
- [Cleanup] = playerConnections,
-
- [Children] = PlayerList {
- PlayerSet = playerSet
+ [Children] = scope:PlayerList {
+ Players = players
}
}
-
```
\ No newline at end of file
diff --git a/docs/examples/index.md b/docs/examples/index.md
index 5eaa91e4a..be7c9b88a 100644
--- a/docs/examples/index.md
+++ b/docs/examples/index.md
@@ -24,7 +24,7 @@ animations and responding to different events.
## Open-Source Projects
-### Fusion Wordle
+### Fusion Wordle (for Fusion 0.2)

@@ -33,7 +33,7 @@ validation, spring animations and sounds.
[Play and edit the game on Roblox.](https://www.roblox.com/games/12178127791/)
-### Fusion Obby
+### Fusion Obby (for Fusion 0.1)

diff --git a/docs/tutorials/animation/springs.md b/docs/tutorials/animation/springs.md
index e6422008c..ed53aa3a2 100644
--- a/docs/tutorials/animation/springs.md
+++ b/docs/tutorials/animation/springs.md
@@ -9,24 +9,17 @@ movement naturally without abrupt changes in direction.
## Usage
-To use `Spring` in your code, you first need to import it from the Fusion
-module, so that you can refer to it by name:
-
-```Lua linenums="1" hl_lines="2"
-local Fusion = require(ReplicatedStorage.Fusion)
-local Spring = Fusion.Spring
-```
-
-To create a new spring object, call the `Spring` function and pass it a state
-object to move towards:
+To create a new spring object, call `scope:Spring()` and pass it a state object
+to move towards:
```Lua
-local goal = Value(0)
-local animated = Spring(target)
+local goal = scope:Value(0)
+local animated = scope:Spring(goal)
```
-The spring will smoothly follow the 'goal' state object over time. As with other
-state objects, you can `peek()` at its value at any time:
+The spring will smoothly follow the 'goal' state object over time.
+
+As with other state objects, you can `peek()` at its value at any time:
```Lua
print(peek(animated)) --> 0.26425...
@@ -36,10 +29,17 @@ To configure how the spring moves, you can provide a speed and damping ratio to
use. Both are optional, and both can be state objects if desired:
```Lua
-local goal = Value(0)
+local goal = scope:Value(0)
local speed = 25
-local damping = Value(0.5)
-local animated = Spring(target, speed, damping)
+local damping = scope:Value(0.5)
+local animated = scope:Spring(goal, speed, damping)
+```
+
+You can also set the position and velocity of the spring at any time.
+
+```Lua
+animated:setPosition(5) -- teleport the spring to 5
+animated:setVelocity(2) -- from here, move 2 units/second
```
You can use many different kinds of values with springs, not just numbers.
@@ -47,8 +47,8 @@ Vectors, CFrames, Color3s, UDim2s and other number-based types are supported;
each number inside the type is animated individually.
```Lua
-local goalPosition = Value(UDim2.new(0.5, 0, 0, 0))
-local animated = Spring(target, 25, 0.5)
+local goalPosition = scope:Value(UDim2.new(0.5, 0, 0, 0))
+local animated = scope:Spring(goalPosition, 25, 0.5)
```
-----
diff --git a/docs/tutorials/animation/tweens.md b/docs/tutorials/animation/tweens.md
index d7a430423..cc7723a3b 100644
--- a/docs/tutorials/animation/tweens.md
+++ b/docs/tutorials/animation/tweens.md
@@ -8,24 +8,17 @@ This can be used for basic, predictable animations.
## Usage
-To use `Tween` in your code, you first need to import it from the Fusion
-module, so that you can refer to it by name:
-
-```Lua linenums="1" hl_lines="2"
-local Fusion = require(ReplicatedStorage.Fusion)
-local Tween = Fusion.Tween
-```
-
-To create a new tween object, call the `Tween` function and pass it a state
-object to move towards:
+To create a new tween object, call `scope:Tween()` and pass it a state object to
+move towards:
```Lua
-local goal = Value(0)
-local animated = Tween(target)
+local goal = scope:Value(0)
+local animated = scope:Tween(goal)
```
-The tween will smoothly follow the 'goal' state object over time. As with other
-state objects, you can `peek()` at its value at any time:
+The tween will smoothly follow the 'goal' state object over time.
+
+As with other state objects, you can `peek()` at its value at any time:
```Lua
print(peek(animated)) --> 0.26425...
@@ -36,9 +29,9 @@ shape of the animation curve. It's optional, and it can be a state object if
desired:
```Lua
-local goal = Value(0)
+local goal = scope:Value(0)
local style = TweenInfo.new(0.5, Enum.EasingStyle.Quad)
-local animated = Tween(target, style)
+local animated = scope:Tween(goal, style)
```
You can use many different kinds of values with tweens, not just numbers.
@@ -46,8 +39,8 @@ Vectors, CFrames, Color3s, UDim2s and other number-based types are supported;
each number inside the type is animated individually.
```Lua
-local goalPosition = Value(UDim2.new(0.5, 0, 0, 0))
-local animated = Tween(target, TweenInfo.new(0.5, Enum.EasingStyle.Quad))
+local goalPosition = scope:Value(UDim2.new(0.5, 0, 0, 0))
+local animated = scope:Tween(goalPosition, TweenInfo.new(0.5, Enum.EasingStyle.Quad))
```
-----
diff --git a/docs/tutorials/components/callbacks.md b/docs/tutorials/best-practices/callbacks.md
similarity index 66%
rename from docs/tutorials/components/callbacks.md
rename to docs/tutorials/best-practices/callbacks.md
index c04e88e15..7a792836e 100644
--- a/docs/tutorials/components/callbacks.md
+++ b/docs/tutorials/best-practices/callbacks.md
@@ -11,17 +11,9 @@ example to report when button clicks occur.
## In Luau
-Callbacks are functions which you pass into other functions. They're part of
-normal Luau code.
-
-```Lua
-workspace.ChildAdded:Connect(function()
- -- this function is a callback!
-end)
-```
-
-They're useful because they allow the function to 'call back' into your code,
-so your code can do something in response:
+Callbacks are functions which you pass into other functions. They're useful
+because they allow the function to 'call back' into your code, so your code can
+do something in response:
```Lua
local function printMessage()
@@ -38,10 +30,12 @@ function. Luau will execute the function, then return to your code.
In this example, the `fiveTimes` function calls a callback five times:
-=== "Script code"
+=== "Luau code"
```Lua
- local function fiveTimes(callback)
+ local function fiveTimes(
+ callback: (number) -> ()
+ )
for x=1, 5 do
callback(x)
end
@@ -69,9 +63,16 @@ In this example, the `fiveTimes` function calls a callback five times:
Components can use callbacks the same way. Consider this button component; when
the button is clicked, the button needs to run some external code:
-```Lua
-local function Button(props)
- return New "TextButton" {
+```Lua hl_lines="17"
+local function Button(
+ scope: Fusion.Scope
+ props: {
+ Position: Fusion.CanBeState?,
+ Size: Fusion.CanBeState?,
+ Text: Fusion.CanBeState?
+ }
+)
+ return scope:New "TextButton" {
BackgroundColor3 = Color3.new(0.25, 0.5, 1),
Position = props.Position,
Size = props.Size,
@@ -84,10 +85,10 @@ local function Button(props)
end
```
-It can ask the controlling code to provide a callback in `props`, called OnClick:
+It can ask the controlling code to provide an `OnClick` callback in `props`.
-```Lua hl_lines="3-5"
-local button = Button {
+```Lua
+local button = scope:Button {
Text = "Hello, world!",
OnClick = function()
print("The button was clicked")
@@ -96,11 +97,20 @@ local button = Button {
```
Assuming that callback is passed in, the callback can be passed directly into
-`[OnEvent]`, because `[OnEvent]` accepts functions:
-
-```Lua hl_lines="10"
-local function Button(props)
- return New "TextButton" {
+`[OnEvent]`, because `[OnEvent]` accepts functions. It can even be optional -
+Luau won't add the key to the table if the value is `nil`.
+
+```Lua hl_lines="7 18"
+local function Button(
+ scope: Fusion.Scope,
+ props: {
+ Position: Fusion.CanBeState?,
+ Size: Fusion.CanBeState?,
+ Text: Fusion.CanBeState?,
+ OnClick: (() -> ())?
+ }
+)
+ return scope:New "TextButton" {
BackgroundColor3 = Color3.new(0.25, 0.5, 1),
Position = props.Position,
Size = props.Size,
@@ -116,9 +126,18 @@ end
Alternatively, we can call `props.OnClick` manually, which is useful if you want
to do your own processing first:
-```Lua hl_lines="10-15"
-local function Button(props)
- return New "TextButton" {
+```Lua hl_lines="19-23"
+local function Button(
+ scope: Fusion.Scope,
+ props: {
+ Position: Fusion.CanBeState?,
+ Size: Fusion.CanBeState?,
+ Text: Fusion.CanBeState?,
+ Disabled: Fusion.CanBeState?,
+ OnClick: (() -> ())?
+ }
+)
+ return scope:New "TextButton" {
BackgroundColor3 = Color3.new(0.25, 0.5, 1),
Position = props.Position,
Size = props.Size,
@@ -127,8 +146,7 @@ local function Button(props)
TextColor3 = Color3.new(1, 1, 1),
[OnEvent "Activated"] = function()
- -- don't send clicks if the button is disabled
- if not peek(props.Disabled) then
+ if props.OnClick ~= nil and not peek(props.Disabled) then
props.OnClick()
end
end
diff --git a/docs/tutorials/components/callbacks/Top-Down-Control-Dark.svg b/docs/tutorials/best-practices/callbacks/Top-Down-Control-Dark.svg
similarity index 100%
rename from docs/tutorials/components/callbacks/Top-Down-Control-Dark.svg
rename to docs/tutorials/best-practices/callbacks/Top-Down-Control-Dark.svg
diff --git a/docs/tutorials/components/callbacks/Top-Down-Control-Light.svg b/docs/tutorials/best-practices/callbacks/Top-Down-Control-Light.svg
similarity index 100%
rename from docs/tutorials/components/callbacks/Top-Down-Control-Light.svg
rename to docs/tutorials/best-practices/callbacks/Top-Down-Control-Light.svg
diff --git a/docs/tutorials/best-practices/components.md b/docs/tutorials/best-practices/components.md
new file mode 100644
index 000000000..f219c47c3
--- /dev/null
+++ b/docs/tutorials/best-practices/components.md
@@ -0,0 +1,340 @@
+You can use functions to create self-contained, reusable blocks of code. In the
+world of UI, you may think of them as *components* - though they can be used for
+much more than just UI.
+
+For example, consider this function, which generates a button based on some
+`props` the user passes in:
+
+```Lua
+local function Button(
+ scope: Fusion.Scope,
+ props: {
+ Position: Fusion.CanBeState?,
+ AnchorPoint: Fusion.CanBeState?,
+ Size: Fusion.CanBeState?,
+ LayoutOrder: Fusion.CanBeState?,
+ ButtonText: Fusion.CanBeState
+ }
+)
+ return scope:New "TextButton" {
+ BackgroundColor3 = Color3.new(0, 0.25, 1),
+ Position = props.Position,
+ AnchorPoint = props.AnchorPoint,
+ Size = props.Size,
+ LayoutOrder = props.LayoutOrder,
+
+ Text = props.ButtonText,
+ TextSize = 28,
+ TextColor3 = Color3.new(1, 1, 1),
+
+ [Children] = UICorner { CornerRadius = UDim2.new(0, 8) }
+ }
+end
+```
+
+You can call this function later to generate as many buttons as you need.
+
+```Lua
+local helloBtn = Button(scope, {
+ ButtonText = "Hello",
+ Size = UDim2.fromOffset(200, 50)
+})
+
+helloBtn.Parent = Players.LocalPlayer.PlayerGui.ScreenGui
+```
+
+Since the `scope` is the first parameter, it can even be used with `scoped()`
+syntax.
+
+```Lua
+local scope = scoped(Fusion, {
+ Button = Button
+})
+
+local helloBtn = scope:Button {
+ ButtonText = "Hello",
+ Size = UDim2.fromOffset(200, 50)
+}
+
+helloBtn.Parent = Players.LocalPlayer.PlayerGui.ScreenGui
+```
+
+This is the primary way of writing components in Fusion. You create functions
+that accept `scope` and `props`, then return some content from them.
+
+-----
+
+## Properties
+
+If you don't say what `props` should contain, it might be hard to figure
+out how to use it.
+
+You can specify your list of properties by adding a type to `props`, which gives
+you useful autocomplete and type checking.
+
+```Lua
+local function Cake(
+ -- ... some stuff here ...
+ props: {
+ Size: Vector3,
+ Colour: Color3,
+ IsTasty: boolean
+ }
+)
+ -- ... some other stuff here ...
+end
+```
+
+Note that the above code only accepts constant values, not state objects. If you
+want to accept *either* a constant or a state object, you can use the
+`CanBeState` type.
+
+```Lua
+local function Cake(
+ -- ... some stuff here ...
+ props: {
+ Size: Fusion.CanBeState,
+ Colour: Fusion.CanBeState,
+ IsTasty: Fusion.CanBeState
+ }
+)
+ -- ... some other stuff here ...
+end
+```
+
+This is usually what you want, because it means the user can easily switch
+a property to dynamically change over time, while still writing properties
+normally when they don't change over time. You can mostly treat `CanBeState`
+properties like they're state objects, because functions like `peek()` and
+`use()` automatically choose the right behaviour for you.
+
+If something *absolutely must* be a state object, you can use the
+`StateObject` type instead. You should only consider this when it doesn't
+make sense for the property to stay the same forever.
+
+```Lua
+local function Cake(
+ -- ... some stuff here ...
+ props: {
+ Size: Fusion.StateObject,
+ Colour: Fusion.StateObject,
+ IsTasty: Fusion.StateObject
+ }
+)
+ -- ... some other stuff here ...
+end
+```
+
+You can use the rest of Luau's type checking features to do more complex
+things, like making certain properties optional, or restricting that values
+are valid for a given property. Go wild!
+
+!!! warning "Be mindful of the angle brackets"
+ Remember that, when working with `StateObject` and `CanBeState`, you should
+ be mindful of whether you're putting things inside the angled brackets, or
+ outside of them. Putting some things inside of the angle brackets can change
+ their meaning, compared to putting them outside of the angle brackets.
+
+ Consider these two type definitions carefully:
+
+ ```Lua
+ -- either nil, or a state object which always stores Vector3
+ Fusion.StateObject?
+
+ -- always a state object, which stores either Vector3 or nil
+ Fusion.StateObject
+ ```
+
+ The first type is best for *optional properties*, where you provide a
+ default value if it isn't specified by the user. If the user *does* specify
+ it, they're forced to always give a valid value for it.
+
+ The second type is best if the property understands `nil` as a valid value.
+ This means the user can set it to `nil` at any time.
+
+-----
+
+## Scopes
+
+In addition to `props`, you should also ask for a `scope`. The `scope`
+parameter should come first, so that your users can use `scoped()` syntax to
+create it.
+
+```Lua
+-- barebones syntax
+local thing = Component(scope, {
+ -- ... some properties here ...
+})
+
+-- scoped() syntax
+local thing = scope:Component {
+ -- ... some properties here ...
+}
+```
+
+It's a good idea to provide a type for `scope`. This lets you specify what
+methods you need the scope to have.
+
+```Lua
+scope: Fusion.Scope
+```
+
+If you don't know what methods to ask for, consider these two strategies.
+
+1. If you only use common methods (like Fusion's constructors) then it's a
+safe assumption that the user will also have those methods. You can ask for a
+scope with those methods pre-defined.
+
+ ```Lua hl_lines="2"
+ local function Component(
+ scope: Fusion.Scope,
+ props: {}
+ )
+ return scope:New "Thing" {
+ -- ... rest of code here ...
+ }
+ end
+ ```
+
+2. If you need more specific or niche things that the user likely won't have
+(for example, components you use internally), then you should not ask for those.
+Instead, create a new inner scope with the methods you need.
+
+ ```Lua hl_lines="2 5-9"
+ local function Component(
+ outerScope: Fusion.Scope<{}>,
+ props: {}
+ )
+ local scope = scoped(Fusion, {
+ SpecialThing1 = require(script.SpecialThing1),
+ SpecialThing2 = require(script.SpecialThing2),
+ })
+ table.insert(outerScope, scope)
+
+ return scope:SpecialThing1 {
+ -- ... rest of code here ...
+ }
+ end
+ ```
+
+If you're not sure which strategy to pick, the second is always a safe fallback,
+because it assumes less about your users and helps hide implementation details.
+
+
+-----
+
+## Modules
+
+It's common to save different components inside of different files.
+There's a number of advantages to this:
+
+- it's easier to find the source code for a specific component
+- it keep each file shorter and simpler
+- it makes sure components are properly independent, and can't interfere
+- it encourages reusing components everywhere, not just in one file
+
+Here's an example of how you could split up some components into modules:
+
+=== "Main file"
+
+ ```Lua linenums="1"
+ local Fusion = require(game:GetService("ReplicatedStorage").Fusion)
+ local scoped, doCleanup = Fusion.scoped, Fusion.doCleanup
+
+ local scope = scoped(Fusion, {
+ PopUp = require(script.Parent.PopUp)
+ })
+
+ local ui = scope:New "ScreenGui" {
+ -- ...some properties...
+
+ [Children] = scope:PopUp {
+ Message = "Hello, world!",
+ DismissText = "Close"
+ }
+ }
+ ```
+=== "PopUp"
+
+ ```Lua linenums="1"
+ local Fusion = require(game:GetService("ReplicatedStorage").Fusion)
+
+ local function PopUp(
+ outerScope: Fusion.Scope<{}>,
+ props: {
+ Message: Fusion.CanBeState,
+ DismissText: Fusion.CanBeState
+ }
+ )
+ local scope = scoped(Fusion, {
+ Message = require(script.Parent.Message),
+ Button = require(script.Parent.Button)
+ })
+ table.insert(outerScope, scope)
+
+ return scope:New "Frame" {
+ -- ...some properties...
+
+ [Children] = {
+ scope:Message {
+ Scope = scope,
+ Text = props.Message
+ }
+ scope:Button {
+ Scope = scope,
+ Text = props.DismissText
+ }
+ }
+ }
+ end
+
+ return PopUp
+ ```
+
+=== "Message"
+
+ ```Lua linenums="1"
+ local Fusion = require(game:GetService("ReplicatedStorage").Fusion)
+
+ local function Message(
+ scope: Fusion.Scope,
+ props: {
+ Text: Fusion.CanBeState
+ }
+ )
+ return scope:New "TextLabel" {
+ AutomaticSize = "XY",
+ BackgroundTransparency = 1,
+
+ -- ...some properties...
+
+ Text = props.Text
+ }
+ end
+
+ return Message
+ ```
+
+=== "Button"
+
+ ```Lua linenums="1"
+ local Fusion = require(game:GetService("ReplicatedStorage").Fusion)
+
+ local function Button(
+ scope: Fusion.Scope,
+ props: {
+ Text: Fusion.CanBeState
+ }
+ )
+ return scope:New "TextButton" {
+ BackgroundColor3 = Color3.new(0.25, 0.5, 1),
+ AutoButtonColor = true,
+
+ -- ...some properties...
+
+ Text = props.Text
+ }
+ end
+
+ return Button
+ ```
\ No newline at end of file
diff --git a/docs/tutorials/best-practices/instance-handling.md b/docs/tutorials/best-practices/instance-handling.md
new file mode 100644
index 000000000..b43722802
--- /dev/null
+++ b/docs/tutorials/best-practices/instance-handling.md
@@ -0,0 +1,187 @@
+Components are a good fit for Roblox instances. You can assemble complex groups
+of instances by combining simpler, self-contained parts.
+
+To ensure maximum compatibility, there are a few best practices to consider.
+
+-----
+
+## Returns
+
+Anything you return from a component should be supported by
+[`[Children]`](../roblox/parenting.md).
+
+```Lua
+-- returns an instance
+return scope:New "Frame" {}
+
+-- returns an array of instances
+return {
+ scope:New "Frame" {},
+ scope:New "Frame" {},
+ scope:New "Frame" {}
+}
+
+-- returns a state object containing instances
+return scope:ForValues({1, 2, 3}, function(use, scope, number)
+ return scope:New "Frame" {}
+end)
+
+-- mix of arrays, instances and state objects
+return {
+ scope:New "Frame" {},
+ {
+ scope:New "Frame" {},
+ scope:ForValues( ... )
+ }
+ scope:ForValues( ... )
+}
+```
+
+!!! fail "Returning multiple values is fragile"
+ Don't return multiple values directly from your function. When a function
+ returns multiple values directly, the extra returned values can easily get
+ lost.
+
+ ```Lua
+ local function BadThing(scope, props)
+ -- returns *multiple* instances (not surrounded by curly braces!)
+ return
+ scope:New "Frame" {},
+ scope:New "Frame" {},
+ scope:New "Frame" {}
+ end
+
+ local things = {
+ -- Luau doesn't let you add multiple returns to a list like this.
+ -- Only the first Frame will be added.
+ scope:BadThing {},
+ scope:New "TextButton" {}
+ }
+ print(things) --> { Frame, TextButton }
+ ```
+
+ Instead, you should return them inside of an array. Because the array is a
+ single return value, it won't get lost.
+
+If your returns are compatible with `[Children]` like above, you can insert a
+component anywhere you'd normally insert an instance.
+
+You can pass in one component on its own...
+
+```Lua hl_lines="2-4"
+local ui = scope:New "ScreenGui" {
+ [Children] = scope:Button {
+ Text = "Hello, world!"
+ }
+}
+```
+
+...you can include components as part of an array..
+
+```Lua hl_lines="5-7 9-11"
+local ui = scope:New "ScreenGui" {
+ [Children] = {
+ scope:New "UIListLayout" {},
+
+ scope:Button {
+ Text = "Hello, world!"
+ },
+
+ scope:Button {
+ Text = "Hello, again!"
+ }
+ }
+}
+```
+
+...and you can return them from state objects, too.
+
+```Lua hl_lines="8-10"
+local stuff = {"Hello", "world", "from", "Fusion"}
+
+local ui = scope:New "ScreenGui" {
+ [Children] = {
+ scope:New "UIListLayout" {},
+
+ scope:ForValues(stuff, function(use, scope, text)
+ return scope:Button {
+ Text = text
+ }
+ end)
+ }
+}
+```
+
+-----
+
+## Containers
+
+Some components, for example pop-ups, might contain lots of different content:
+
+
+
+
+Ideally, you would be able to reuse the pop-up 'container', while placing your
+own content inside.
+
+
+
+
+The simplest way to do this is to pass instances through to `[Children]`. For
+example, if you accept a table of `props`, you can add a `[Children]` key:
+
+```Lua hl_lines="4 8"
+local function PopUp(
+ scope: Fusion.Scope,
+ props: {
+ [typeof(Children)]: Fusion.Children
+ }
+)
+ return scope:New "Frame" {
+ [Children] = props[Children]
+ }
+end
+```
+
+!!! tip "Accepting multiple instances"
+ If you have multiple 'slots' where you want to pass through instances, you
+ can make other properties and give them the `Fusion.Children` type.
+
+Later on, when a pop-up is created, instances can now be parented into that
+pop-up:
+
+```Lua
+scope:PopUp {
+ [Children] = {
+ scope:Label {
+ Text = "New item collected"
+ },
+ scope:ItemPreview {
+ Item = Items.BRICK
+ },
+ scope:Button {
+ Text = "Add to inventory"
+ }
+ }
+}
+```
+
+If you need to add other instances, you can still use arrays and state objects
+as normal. You can include instances you're given, in exactly the same way you
+would include any other instances.
+
+```Lua
+scope:New "Frame" {
+ -- ... some other properties ...
+
+ [Children] = {
+ -- the component provides some children here
+ scope:New "UICorner" {
+ CornerRadius = UDim.new(0, 8)
+ },
+
+ -- include children from outside the component here
+ props[Children]
+ }
+}
+```
\ No newline at end of file
diff --git a/docs/tutorials/components/children/Popup-Exploded-Dark.svg b/docs/tutorials/best-practices/instance-handling/Popup-Exploded-Dark.svg
similarity index 100%
rename from docs/tutorials/components/children/Popup-Exploded-Dark.svg
rename to docs/tutorials/best-practices/instance-handling/Popup-Exploded-Dark.svg
diff --git a/docs/tutorials/components/children/Popup-Exploded-Light.svg b/docs/tutorials/best-practices/instance-handling/Popup-Exploded-Light.svg
similarity index 100%
rename from docs/tutorials/components/children/Popup-Exploded-Light.svg
rename to docs/tutorials/best-practices/instance-handling/Popup-Exploded-Light.svg
diff --git a/docs/tutorials/components/children/Popups-Dark.svg b/docs/tutorials/best-practices/instance-handling/Popups-Dark.svg
similarity index 100%
rename from docs/tutorials/components/children/Popups-Dark.svg
rename to docs/tutorials/best-practices/instance-handling/Popups-Dark.svg
diff --git a/docs/tutorials/components/children/Popups-Light.svg b/docs/tutorials/best-practices/instance-handling/Popups-Light.svg
similarity index 100%
rename from docs/tutorials/components/children/Popups-Light.svg
rename to docs/tutorials/best-practices/instance-handling/Popups-Light.svg
diff --git a/docs/tutorials/best-practices/sharing-values.md b/docs/tutorials/best-practices/sharing-values.md
new file mode 100644
index 000000000..f77ec6143
--- /dev/null
+++ b/docs/tutorials/best-practices/sharing-values.md
@@ -0,0 +1,293 @@
+Sometimes values are used in far-away parts of the codebase. For example, many
+UI elements might share theme colours for light and dark theme.
+
+-----
+
+## Globals
+
+Typically, values are shared by placing them in modules. These modules can be
+required from anywhere in the codebase, and their values can be imported into
+any code.
+
+Values shared in this way are known as *globals*.
+
+=== "Theme.luau"
+
+ ```Lua linenums="1"
+ local Theme = {}
+
+ Theme.colours = {
+ background = Color3.fromHex("FFFFFF"),
+ text = Color3.fromHex("222222"),
+ -- etc.
+ }
+
+ return Theme
+ ```
+
+=== "Somewhere else"
+
+ ```Lua linenums="1"
+ local Theme = require("path/to/Theme.luau")
+
+ local textColour = Theme.colours.text
+ print(textColour) --> 34, 34, 34
+ ```
+
+In particular, you can share state objects this way, and every part of the
+codebase will be able to see and interact with those state objects.
+
+=== "Theme.luau"
+
+ ```Lua linenums="1"
+ local Fusion = require("path/to/Fusion.luau")
+
+ local Theme = {}
+
+ Theme.colours = {
+ background = {
+ light = Color3.fromHex("FFFFFF"),
+ dark = Color3.fromHex("222222")
+ },
+ text = {
+ light = Color3.fromHex("FFFFFF"),
+ dark = Color3.fromHex("222222")
+ },
+ -- etc.
+ }
+
+ function Theme.init(
+ scope: Fusion.Scope
+ )
+ Theme.currentTheme = scope:Value("light")
+ end
+
+ return Theme
+ ```
+
+=== "Somewhere else"
+
+ ```Lua linenums="1"
+ local Fusion = require("path/to/Fusion.luau")
+ local scoped, peek = Fusion.scoped, Fusion.peek
+
+ local Theme = require("path/to/Theme.luau")
+
+ local function printTheme()
+ local theme = Theme.currentTheme
+ print(
+ peek(theme),
+ if typeof(theme) == "string" then "constant" else "state object"
+ )
+ end
+
+ local scope = scoped(Fusion)
+ Theme.init(scope)
+ printTheme() --> light state object
+
+ Theme.currentTheme:set("dark")
+ printTheme() --> dark state object
+ ```
+
+Globals are very straightforward to implement and can be useful, but they can
+quickly cause problems if not used carefully.
+
+### Hidden dependencies
+
+When you use a global inside a block of reusable code such as a component, you
+are making your code dependent on another code file without declaring it to the
+outside world.
+
+To some extent, this is entirely why using globals is desirable. While it's more
+'correct' to accept the `Theme` via the parameters of your function, it often
+means the `Theme` has to be passed down through multiple layers of functions.
+This is known as [prop drilling](https://kentcdodds.com/blog/prop-drilling) and
+is widely considered bad practice, because it clutters up unrelated code with
+parameters that are only passed through functions.
+
+To avoid prop drilling, globals are often used, which 'hides' the dependency on
+that external code file. You no longer have to pass it down through parameters.
+However, relying too heavily on these hidden dependencies can cause your code to
+behave in surprising, unintuitive ways, or it can obscure what functionality is
+available to developers using your code.
+
+### Hard-to-locate writes
+
+If you write into globals from deep inside your code base, it becomes very hard
+to figure out where the global is being changed from, which significantly hurts
+debugging.
+
+Generally, it's best to treat globals as *read-only*. If you're writing to a
+global, it should be coming from a single well-signposted, easy-to-find place.
+
+You should also keep the principles of [top-down control](../callbacks) in mind;
+think of globals as 'flowing down' from the root of the program. Globals are
+best managed from high up in the program, because they have widespread effects,
+so consider using callbacks to pass control up the chain, rather than managing
+globals directly from every part of the code base.
+
+### Memory management
+
+In addition, globals can complicate memory management. Because every part of
+your code base can access them, you can't destroy globals until the very end of
+your program.
+
+In the above example, this is solved with an `init()` method which passes the
+main scope to `Theme`. Because `init()` is called before anything else that uses
+`Theme`, the objects that `Theme` creates will be added to the scope first.
+
+When the main scope is cleaned up, `doCleanup()` will destroy things in reverse
+order. This means the `Theme` objects will be cleaned up last, after everything
+else in the program has been cleaned up.
+
+This only works if you know that the main script is the only entry point in your
+program. If you have two scripts running concurrently which try to `init()` the
+`Theme` module, they will overwrite each other.
+
+### Non-replaceable for testing
+
+When your code uses a global, you're hard-coding a connection between your code
+and that specific global.
+
+This is problematic for testing; unless you're using an advanced testing
+framework with code injection, it's pretty much impossible to separate your code
+from that global code, which makes it impossible to replace global values for
+testing purposes.
+
+For example, if you wanted to write automated tests that verify light theme and
+dark theme are correctly applied throughout your UI, you can't replace any
+values stored in `Theme`.
+
+You might be able to write to the `Theme` by going through the normal process,
+but this fundamentally limits how you can test. For example, you couldn't run a
+test for light theme and dark theme at the same time.
+
+-----
+
+## Contextuals
+
+The main drawback of globals is that they hold one value for all code. To solve
+this, Fusion introduces *contextual values*, which can be temporarily changed
+for the duration of a code block.
+
+To create a contextual, call the `Contextual` function from Fusion. It asks for
+a default value.
+
+```Lua
+local myContextual = Contextual("foo")
+```
+
+At any time, you can query its current value using the `:now()` method.
+
+```Lua
+local myContextual = Contextual("foo")
+print(myContextual:now()) --> foo
+```
+
+You can override the value for a limited span of time using `:is():during()`.
+Pass the temporary value to `:is()`, and pass a callback to `:during()`.
+
+While the callback is running, the contextual will adopt the temporary value.
+
+```Lua
+local myContextual = Contextual("foo")
+print(myContextual:now()) --> foo
+
+myContextual:is("bar"):during(function()
+ print(myContextual:now()) --> bar
+end)
+
+print(myContextual:now()) --> foo
+```
+
+By storing widely-used values inside contextuals, you can isolate different
+code paths from each other, while retaining the easy, hidden referencing that
+globals offer. This makes testing and memory management significantly easier,
+and helps you locate which code is modifying any shared values.
+
+To demonstrate, the `Theme` example can be rewritten to use contextuals.
+
+=== "Theme.luau"
+
+ ```Lua linenums="1"
+ local Fusion = require("path/to/Fusion.luau")
+ local Contextual = Fusion.Contextual
+
+ local Theme = {}
+
+ Theme.colours = {
+ background = {
+ light = Color3.fromHex("FFFFFF"),
+ dark = Color3.fromHex("222222")
+ },
+ text = {
+ light = Color3.fromHex("FFFFFF"),
+ dark = Color3.fromHex("222222")
+ },
+ -- etc.
+ }
+
+ Theme.currentTheme = Contextual("light")
+
+ return Theme
+ ```
+
+=== "Somewhere else"
+
+ ```Lua linenums="1"
+ local Fusion = require("path/to/Fusion.luau")
+ local scoped, peek = Fusion.scoped, Fusion.peek
+
+ local Theme = require("path/to/Theme.luau")
+
+ local function printTheme()
+ local theme = Theme.currentTheme:now()
+ print(
+ peek(theme),
+ if typeof(theme) == "string" then "constant" else "state object"
+ )
+ end
+
+ printTheme() --> light constant
+
+ local scope = scoped(Fusion)
+ local override = scope:Value("light")
+ Theme.currentTheme:is(override):during(function()
+ printTheme() --> light state object
+ override:set("dark")
+ printTheme() --> dark state object
+ end)
+
+ printTheme() --> light constant
+ ```
+
+In this rewritten example, `Theme` no longer requires an `init()` function,
+because - instead of defining a state object globally - `Theme` only defines
+`"light"` as the default value.
+
+You're expected to replace the default value with a state object when you want
+to make the theme dynamic. This has a number of benefits:
+
+- Because the override is time-limited to one span of your code, you can have
+multiple scripts running at the same time with completely different overrides.
+
+- It also explicitly places your code in charge of memory management, because
+you're creating the object yourself.
+
+- It's easy to locate where changes are coming from, because you can look for
+the nearest `:is():during()` call. Optionally, you could share a limited,
+read-only version of the value, while retaining private access to write to the
+value wherever you're overriding the contextual from.
+
+- Testing becomes much simpler; you can override the contextual for different
+parts of your testing, without ever having to inject code, and without altering
+how you read and override the contextual in your production code.
+
+It's still possible to run into issues with contextuals, though.
+
+- You're still hiding a dependency of your code, which can still lead to
+confusion and obscuring available features, just the same as globals.
+- Unlike globals, contextuals are time-limited. If you connect to an event or
+start a delayed task, you won't be able to access the value anymore. Instead,
+capture the value at the start of the code block, so you can use it in delayed
+tasks.
\ No newline at end of file
diff --git a/docs/tutorials/best-practices/state.md b/docs/tutorials/best-practices/state.md
new file mode 100644
index 000000000..694dee4d3
--- /dev/null
+++ b/docs/tutorials/best-practices/state.md
@@ -0,0 +1,215 @@
+Components can hold their own data privately using state objects. This can be
+useful, but you should be careful when adding state.
+
+-----
+
+## Creation
+
+You can create state objects inside components as you would anywhere else.
+
+```Lua hl_lines="10 13-15"
+local HOVER_COLOUR = Color3.new(0.5, 0.75, 1)
+local REST_COLOUR = Color3.new(0.25, 0.5, 1)
+
+local function Button(
+ scope: Fusion.Scope,
+ props: {
+ -- ... some properties ...
+ }
+)
+ local isHovering = scope:Value(false)
+
+ return scope:New "TextButton" {
+ BackgroundColor3 = scope:Computed(function(use)
+ return if use(isHovering) then HOVER_COLOUR else REST_COLOUR
+ end),
+
+ -- ... ... some more code ...
+ }
+end
+```
+
+Because these state objects are made with the same `scope` as the rest of the
+component, they're destroyed alongside the rest of the component.
+
+-----
+
+## Top-Down Control
+
+Remember that Fusion mainly works with a top-down flow of control. It's a good
+idea to keep that in mind when adding state to components.
+
+When you're making reusable components, it's more flexible if your component can
+be controlled externally. Components that control themselves entirely are hard
+to use and customise.
+
+Consider the example of a check box. Each check box often reflects a state
+object under the hood:
+
+
+
+
+It might *seem* logical to store the state object inside the check box, but
+this causes a few problems:
+
+- because the state is hidden, it's awkward to read and write from outside
+- often, the user already has a state object representing the same setting, so
+now there's two state objects where one would have sufficed
+
+```Lua hl_lines="7"
+local function CheckBox(
+ scope: Fusion.Scope,
+ props: {
+ -- ... some properties ...
+ }
+)
+ local isChecked = scope:Value(false) -- problematic
+
+ return scope:New "ImageButton" {
+ [OnEvent "Activated"] = function()
+ isChecked:set(not peek(isChecked))
+ end,
+
+ -- ... some more code ...
+ }
+end
+```
+
+A *slightly better* solution is to pass the state object in. This ensures the
+controlling code has easy access to the state if it needs it. However, this is
+not a complete solution:
+
+- the user is forced to store the state in a `Value` object, but they might be
+computing the value dynamically with other state objects instead
+- the behaviour of clicking the check box is hardcoded; the user cannot
+intercept the click or toggle a different state
+
+```Lua hl_lines="4"
+local function CheckBox(
+ scope: Fusion.Scope