This commit is contained in:
lx
2026-07-06 14:01:11 -04:00
commit c4f97d729d
16468 changed files with 935321 additions and 0 deletions
@@ -0,0 +1,12 @@
--[[
Package link auto-generated by Rotriever
]]
local PackageIndex = script.Parent.Parent
local package = PackageIndex["roblox_cryo"]["cryo"]
if package.ClassName == "ModuleScript" then
return require(package)
end
return package
@@ -0,0 +1,12 @@
--[[
Package link auto-generated by Rotriever
]]
local PackageIndex = script.Parent.Parent
local package = PackageIndex["roblox_otter"]["otter"]
if package.ClassName == "ModuleScript" then
return require(package)
end
return package
@@ -0,0 +1,12 @@
--[[
Package link auto-generated by Rotriever
]]
local PackageIndex = script.Parent.Parent
local package = PackageIndex["roblox_roact"]["roact"]
if package.ClassName == "ModuleScript" then
return require(package)
end
return package
@@ -0,0 +1,12 @@
--[[
Package link auto-generated by Rotriever
]]
local PackageIndex = script.Parent.Parent
local package = PackageIndex["roblox_state-table"]["state-table"]
if package.ClassName == "ModuleScript" then
return require(package)
end
return package
@@ -0,0 +1,11 @@
# Generated by Rotriever. Format subject to change in future releases.
name = "roblox/roact-navigation"
version = "0.2.8"
commit = "6b5c6975e75ab68048a12f6f3b84ab4061afe496"
source = "url+https://github.com/roblox/roact-navigation"
dependencies = [
"Cryo roblox/cryo 1.0.0 url+https://github.com/roblox/cryo",
"Otter roblox/otter 0.1.2 url+https://github.com/roblox/otter",
"Roact roblox/roact 1.3.0 url+https://github.com/roblox/roact",
"StateTable roblox/state-table 0.1.0 url+https://github.com/roblox/state-table",
]
@@ -0,0 +1,21 @@
local NavigationSymbol = require(script.Parent.NavigationSymbol)
local NONE_TOKEN = NavigationSymbol("NONE")
local INITIAL_ROUTE_TOKEN = NavigationSymbol("INITIAL_ROUTE")
local ORDER_TOKEN = NavigationSymbol("ORDER")
local HISTORY_TOKEN = NavigationSymbol("HISTORY")
--[[
BackBehavior provides shared constants that are used to configure back
action styles for different navigators. Note that not all routers support
all BackBehaviors and they will fall back to appropriate defaults for
those cases.
]]
local BackBehavior = {
None = NONE_TOKEN,
InitialRoute = INITIAL_ROUTE_TOKEN,
Order = ORDER_TOKEN,
History = HISTORY_TOKEN,
}
return BackBehavior
@@ -0,0 +1,76 @@
local NavigationSymbol = require(script.Parent.NavigationSymbol)
local BACK_TOKEN = NavigationSymbol("BACK")
local INIT_TOKEN = NavigationSymbol("INIT")
local NAVIGATE_TOKEN = NavigationSymbol("NAVIGATE")
local SET_PARAMS_TOKEN = NavigationSymbol("SET_PARAMS")
local COMPLETE_TRANSITION_TOKEN = NavigationSymbol("COMPLETE_TRANSITION")
--[[
NavigationActions provides shared constants and methods to construct
actions that are dispatched to routers to cause a change in the route.
]]
local NavigationActions = {
Back = BACK_TOKEN,
Init = INIT_TOKEN,
Navigate = NAVIGATE_TOKEN,
SetParams = SET_PARAMS_TOKEN,
CompleteTransition = COMPLETE_TRANSITION_TOKEN,
}
NavigationActions.__index = NavigationActions
-- Navigate back in the history (temporally).
function NavigationActions.back(payload)
local data = payload or {}
return {
type = BACK_TOKEN,
key = data.key,
immediate = data.immediate,
}
end
-- Initialize the navigation history if not already defined.
function NavigationActions.init(payload)
local data = payload or {}
return {
type = INIT_TOKEN,
params = data.params,
}
end
-- Navigate to an existing or new route.
function NavigationActions.navigate(payload)
local data = payload or {}
return {
type = NAVIGATE_TOKEN,
routeName = data.routeName,
params = data.params,
action = data.action,
key = data.key,
}
end
-- Swap out the params for an existing route, matched by the given key.
function NavigationActions.setParams(payload)
local data = payload or {}
return {
type = SET_PARAMS_TOKEN,
key = data.key,
params = data.params,
}
end
-- For internal use. Triggers completion of a transition animation, if needed by the router.
-- This would be sent on e.g. didMount of the new page, so the router knows that the new screen
-- is ready to be displayed before it animates it in place.
function NavigationActions.completeTransition(payload)
local data = payload or {}
return {
type = COMPLETE_TRANSITION_TOKEN,
key = data.key,
toChildKey = data.toChildKey,
}
end
return NavigationActions
@@ -0,0 +1,21 @@
local NavigationSymbol = require(script.Parent.NavigationSymbol)
local WILL_FOCUS_TOKEN = NavigationSymbol("WILL_FOCUS")
local DID_FOCUS_TOKEN = NavigationSymbol("DID_FOCUS")
local WILL_BLUR_TOKEN = NavigationSymbol("WILL_BLUR")
local DID_BLUR_TOKEN = NavigationSymbol("DID_BLUR")
local ACTION_TOKEN = NavigationSymbol("ACTION")
local REFOCUS_TOKEN = NavigationSymbol("REFOCUS")
--[[
NavigationEvents provides shared constants that are used to register
listeners for different RoactNavigation UI state changes.
]]
return {
WillFocus = WILL_FOCUS_TOKEN,
DidFocus = DID_FOCUS_TOKEN,
WillBlur = WILL_BLUR_TOKEN,
DidBlur = DID_BLUR_TOKEN,
Action = ACTION_TOKEN,
Refocus = REFOCUS_TOKEN,
}
@@ -0,0 +1,15 @@
-- Taken from Roact.Symbol and modified to produce exact string names
-- to allow for serialization/pathing.
return function (name)
assert(type(name) == "string", "Symbols must be created using a string name!")
local self = newproxy(true)
-- Unlike Symbols in Roact, we need the exact names.
getmetatable(self).__tostring = function()
return name
end
return self
end
@@ -0,0 +1,11 @@
local NavigationSymbol = require(script.Parent.NavigationSymbol)
--[[
RoactNavigation.None allows us to declare that certain values should be explicitly
removed from navigation props, e.g. for stack navigation options where we want to
remove the default header from a screen when drawing in a stack navigator with
headerMode == StackHeaderMode.Screen.
]]
local NONE_SYMBOL = NavigationSymbol("NONE")
return NONE_SYMBOL
@@ -0,0 +1,80 @@
local NavigationSymbol = require(script.Parent.NavigationSymbol)
local POP_TOKEN = NavigationSymbol("POP")
local POP_TO_TOP_TOKEN = NavigationSymbol("POP_TO_TOP")
local PUSH_TOKEN = NavigationSymbol("PUSH")
local RESET_TOKEN = NavigationSymbol("RESET")
local REPLACE_TOKEN = NavigationSymbol("REPLACE")
--[[
StackActions provides shared constants and methods to construct
actions that are dispatched to routers to cause a change in the route.
These actions are specific to Stack navigation. See NavigationActions
if you need to use more general APIs.
]]
local StackActions = {
Pop = POP_TOKEN,
PopToTop = POP_TO_TOP_TOKEN,
Push = PUSH_TOKEN,
Reset = RESET_TOKEN,
Replace = REPLACE_TOKEN,
}
StackActions.__index = StackActions
-- Pop the top-most item off the route stack, if any.
function StackActions.pop(payload)
local data = payload or {}
return {
type = POP_TOKEN,
n = data.n,
}
end
-- Pop all the items except the last one off the route stack.
function StackActions.popToTop(payload)
local data = payload or {}
return {
type = POP_TO_TOP_TOKEN,
key = data.key,
}
end
-- Push a new item onto the route stack.
function StackActions.push(payload)
local data = payload or {}
return {
type = PUSH_TOKEN,
routeName = data.routeName,
params = data.params,
action = data.action,
}
end
-- Reset the route stack and replace it with a new stack,
-- specified by a list of actions to be applied.
function StackActions.reset(payload)
local data = payload or {}
return {
type = RESET_TOKEN,
index = data.index,
actions = data.actions,
key = data.key,
}
end
-- Replace the route for the given key with a new route.
function StackActions.replace(payload)
local data = payload or {}
return {
type = REPLACE_TOKEN,
key = data.key,
newKey = data.newKey,
routeName = data.routeName,
params = data.params,
action = data.action,
immediate = data.immediate,
}
end
return StackActions
@@ -0,0 +1,262 @@
local Cryo = require(script.Parent.Parent.Cryo)
--[[
StateUtils provides utilities to read and write standard route data.
Routes have the following general structure:
{
index = <required integer: current active route in list>,
routes = [
{
routeName = <required string: navigation name for route>,
key = <unique string: identifier for this route>,
params = <optional dictionary: params for screen>,
action = <optional dictionary: sub-action to be run by child routers>,
},
...
]
}
This structure is independent of the notion of stack, tab, drawer, or any
other kind of navigation. It simply represents a list of pages and their
parameters. Different kinds of routers can treat the data in their own way.
]]
local StateUtils = {}
StateUtils.__index = StateUtils
-- Get the route matching the given key. Returns nil if no match is found.
function StateUtils.get(state, key)
assert(type(state) == "table", "state must be a table")
assert(type(key) == "string", "key must be a string")
for _, route in ipairs(state.routes) do
if route.key == key then
return route
end
end
return nil
end
-- Get the route at the given index. Returns nil if no match is found.
function StateUtils.getAtIndex(state, index)
assert(type(state) == "table", "state must be a table")
assert(type(index) == "number", "index must be a number")
assert(index >= 0, "index must be non-negative")
return state.routes[index]
end
-- Get the active route from state. Returns nil if no routes.
function StateUtils.getActiveRoute(state)
assert(type(state) == "table", "state must be a table")
local index = state.index
if index <= 0 then
return nil
end
return state.routes[index]
end
-- Get the index of the route matching the given key. Returns nil if no match is found.
function StateUtils.indexOf(state, key)
assert(type(state) == "table", "state must be a table")
assert(type(key) == "string", "key must be a string")
for index, route in ipairs(state.routes) do
if route.key == key then
return index
end
end
return nil
end
-- Returns true if a route exists matching the given key, false otherwise.
function StateUtils.has(state, key)
assert(type(state) == "table", "state must be a table")
assert(type(key) == "string", "key must be a string")
for _, route in ipairs(state.routes) do
if route.key == key then
return true
end
end
return false
end
-- Push a new route into the navigation state. Makes the pushed route active.
function StateUtils.push(state, route)
assert(type(state) == "table", "state must be a table")
assert(type(route) == "table", "route must be a table")
assert(StateUtils.indexOf(state, route.key) == nil,
string.format("route with key '%s' already exists", route.key))
local routes = Cryo.List.join(state.routes, { route })
return Cryo.Dictionary.join(state, {
index = #routes,
routes = routes,
})
end
-- Pop the top-most route from the navigation state (NOT the active route).
-- Makes the new top-most route active.
function StateUtils.pop(state)
assert(type(state) == "table", "state must be a table")
if #state.routes == 0 then
-- NOTE: Popping empty state is a no-op
return state
end
local routes = Cryo.List.removeIndex(state.routes, #state.routes)
return Cryo.Dictionary.join(state, {
index = #routes,
routes = routes,
})
end
-- Sets the active route to match the given index.
function StateUtils.jumpToIndex(state, index)
assert(type(state) == "table", "state must be a table")
assert(type(index) == "number", "index must be a number")
if index == state.index then
return state
end
assert(state.routes[index] ~= nil,
string.format("cannot jump to out-of-range index '%d'", index))
return Cryo.Dictionary.join(state, {
index = index,
})
end
-- Sets the active route to match the given key.
function StateUtils.jumpTo(state, key)
assert(type(state) == "table", "state must be a table")
assert(type(key) == "string", "key must be a string")
local index = StateUtils.indexOf(state, key)
return StateUtils.jumpToIndex(state, index)
end
-- Sets the active route to the previous route in the list.
function StateUtils.back(state)
assert(type(state) == "table", "state must be a table")
local index = state.index - 1
if not state.routes[index] then
return state
end
return StateUtils.jumpToIndex(state, index)
end
-- Sets the active route to the next route in the list.
function StateUtils.forward(state)
assert(type(state) == "table", "state must be a table")
local index = state.index + 1
if not state.routes[index] then
return state
end
return StateUtils.jumpToIndex(state, index)
end
-- Replace the route matching the given key. Sets the active route to the
-- newly replaced entry. Prunes the old entries that follow the replaced one.
function StateUtils.replaceAndPrune(state, key, route)
assert(type(state) == "table", "state must be a table")
assert(type(key) == "string", "key must be a string")
assert(type(route) == "table", "route must be a table")
local index = StateUtils.indexOf(state, key)
local replaced = StateUtils.replaceAtIndex(state, index, route)
return Cryo.Dictionary.join(replaced, {
routes = { unpack(replaced.routes, 1, index) }
})
end
-- Replace the route matching the given key without pruning the following routes.
-- The active route will be updated to match the newly replaced one unless
-- preserveIndex is true.
function StateUtils.replaceAt(state, key, route, preserveIndex)
assert(type(state) == "table", "state must be a table")
assert(type(key) == "string", "key must be a string")
assert(type(route) == "table", "route must be a table")
assert(preserveIndex == nil or type(preserveIndex) == "boolean",
"preserveIndex must be nil or a boolean")
local index = StateUtils.indexOf(state, key)
local nextIndex = preserveIndex and state.index or index
local nextState = StateUtils.replaceAtIndex(state, index, route)
nextState.index = nextIndex
return nextState
end
-- Replace the route at the given index. Updates the active route to point to
-- the replaced entry.
function StateUtils.replaceAtIndex(state, index, route)
assert(type(state) == "table", "state must be a table")
assert(type(index) == "number", "index must be a number")
assert(type(route) == "table", "route must be a table")
assert(state.routes[index] ~= nil,
string.format("index '%d' does not exist in route '%s'", index, route.key))
if state.routes[index] == route and index == state.index then
return state
end
local routes = Cryo.List.join(state.routes)
routes[index] = route
return Cryo.Dictionary.join(state, {
index = index,
routes = routes,
})
end
-- Wipe away the existing routes and replace them with new routes.
-- Sets the active route to the provided index (if provided), otherwise
-- sets the active route to the last one in the list.
function StateUtils.reset(state, routes, index)
assert(type(state) == "table", "state must be a table")
assert(type(routes) == "table" and #routes > 0,
"routes must be a list with at least one element")
assert(index == nil or type(index) == "number",
"index must be a number or nil")
local nextIndex = not index and #routes or index
-- Bail out without replacing IFF index and routes all match
if #state.routes == #routes and state.index == nextIndex then
local routesAreEqual = true
for i = 1, #routes, 1 do
if state.routes[i] ~= routes[i] then
routesAreEqual = false
break
end
end
if routesAreEqual then
return state
end
end
assert(routes[nextIndex] ~= nil,
string.format("cannot reset index '%d' that does not exist", nextIndex))
return Cryo.Dictionary.join(state, {
index = nextIndex,
routes = routes,
})
end
return StateUtils
@@ -0,0 +1,19 @@
return function()
local BackBehavior = require(script.Parent.Parent.BackBehavior)
describe("BackBehavior token tests", function()
it("should return same object for each token for multiple calls", function()
expect(BackBehavior.None).to.equal(BackBehavior.None)
expect(BackBehavior.InitialRoute).to.equal(BackBehavior.InitialRoute)
expect(BackBehavior.Order).to.equal(BackBehavior.Order)
expect(BackBehavior.History).to.equal(BackBehavior.History)
end)
it("should return matching string names for symbols", function()
expect(tostring(BackBehavior.None)).to.equal("NONE")
expect(tostring(BackBehavior.InitialRoute)).to.equal("INITIAL_ROUTE")
expect(tostring(BackBehavior.Order)).to.equal("ORDER")
expect(tostring(BackBehavior.History)).to.equal("HISTORY")
end)
end)
end
@@ -0,0 +1,80 @@
return function()
local NavigationActions = require(script.Parent.Parent.NavigationActions)
describe("NavigationActions token tests", function()
it("should return same object for each token for multiple calls", function()
expect(NavigationActions.Back).to.equal(NavigationActions.Back)
expect(NavigationActions.Init).to.equal(NavigationActions.Init)
expect(NavigationActions.Navigate).to.equal(NavigationActions.Navigate)
expect(NavigationActions.SetParams).to.equal(NavigationActions.SetParams)
expect(NavigationActions.CompleteTransition).to.equal(NavigationActions.CompleteTransition)
end)
it("should return matching string names for symbols", function()
expect(tostring(NavigationActions.Back)).to.equal("BACK")
expect(tostring(NavigationActions.Init)).to.equal("INIT")
expect(tostring(NavigationActions.Navigate)).to.equal("NAVIGATE")
expect(tostring(NavigationActions.SetParams)).to.equal("SET_PARAMS")
expect(tostring(NavigationActions.CompleteTransition)).to.equal("COMPLETE_TRANSITION")
end)
end)
describe("NavigationActions function tests", function()
it("should return a back action with matching data for a call to back()", function()
local backTable = NavigationActions.back({
key = "the_key",
immediate = true,
})
expect(backTable.type).to.equal(NavigationActions.Back)
expect(backTable.key).to.equal("the_key")
expect(backTable.immediate).to.equal(true)
end)
it("should return an init action with matching data for call to init()", function()
local initTable = NavigationActions.init({
params = "foo",
})
expect(initTable.type).to.equal(NavigationActions.Init)
expect(initTable.params).to.equal("foo")
end)
it("should return a navigate action with matching data for call to navigate()", function()
local navigateTable = NavigationActions.navigate({
routeName = "routeName",
params = "foo",
action = "action",
key = "key",
})
expect(navigateTable.type).to.equal(NavigationActions.Navigate)
expect(navigateTable.routeName).to.equal("routeName")
expect(navigateTable.params).to.equal("foo")
expect(navigateTable.action).to.equal("action")
expect(navigateTable.key).to.equal("key")
end)
it("should return a set params action with matching data for call to setParams()", function()
local setParamsTable = NavigationActions.setParams({
key = "key",
params = "foo",
})
expect(setParamsTable.type).to.equal(NavigationActions.SetParams)
expect(setParamsTable.key).to.equal("key")
expect(setParamsTable.params).to.equal("foo")
end)
it("should return a complete transition action with matching data for call to completeTransition()", function()
local completeTransitionTable = NavigationActions.completeTransition({
key = "key",
toChildKey = "toChildKey",
})
expect(completeTransitionTable.type).to.equal(NavigationActions.CompleteTransition)
expect(completeTransitionTable.key).to.equal("key")
expect(completeTransitionTable.toChildKey).to.equal("toChildKey")
end)
end)
end
@@ -0,0 +1,23 @@
return function()
local NavigationEvents = require(script.Parent.Parent.NavigationEvents)
describe("NavigationEvents token tests", function()
it("should return same object for each token for multiple calls", function()
expect(NavigationEvents.WillFocus).to.equal(NavigationEvents.WillFocus)
expect(NavigationEvents.DidFocus).to.equal(NavigationEvents.DidFocus)
expect(NavigationEvents.WillBlur).to.equal(NavigationEvents.WillBlur)
expect(NavigationEvents.DidBlur).to.equal(NavigationEvents.DidBlur)
expect(NavigationEvents.Action).to.equal(NavigationEvents.Action)
expect(NavigationEvents.Refocus).to.equal(NavigationEvents.Refocus)
end)
it("should return matching string names for symbols", function()
expect(tostring(NavigationEvents.WillFocus)).to.equal("WILL_FOCUS")
expect(tostring(NavigationEvents.DidFocus)).to.equal("DID_FOCUS")
expect(tostring(NavigationEvents.WillBlur)).to.equal("WILL_BLUR")
expect(tostring(NavigationEvents.DidBlur)).to.equal("DID_BLUR")
expect(tostring(NavigationEvents.Action)).to.equal("ACTION")
expect(tostring(NavigationEvents.Refocus)).to.equal("REFOCUS")
end)
end)
end
@@ -0,0 +1,22 @@
return function()
local NavigationSymbol = require(script.Parent.Parent.NavigationSymbol)
it("should give an opaque object", function()
local symbol = NavigationSymbol("foo")
expect(symbol).to.be.a("userdata")
end)
it("should coerce to the given name", function()
local symbol = NavigationSymbol("foo")
expect(tostring(symbol)).to.equal("foo")
end)
it("should be unique when constructed", function()
local symbolA = NavigationSymbol("abc")
local symbolB = NavigationSymbol("abc")
expect(symbolA).never.to.equal(symbolB)
end)
end
@@ -0,0 +1,11 @@
return function()
local NoneSymbol = require(script.Parent.Parent.NoneSymbol)
it("should return same object for each token for multiple calls", function()
expect(NoneSymbol).to.equal(NoneSymbol)
end)
it("should return matching string names for symbols", function()
expect(tostring(NoneSymbol)).to.equal("NONE")
end)
end
@@ -0,0 +1,82 @@
return function()
local StackActions = require(script.Parent.Parent.StackActions)
describe("StackActions token tests", function()
it("should return same object for each token for multiple calls", function()
expect(StackActions.Pop).to.equal(StackActions.Pop)
expect(StackActions.PopToTop).to.equal(StackActions.PopToTop)
expect(StackActions.Push).to.equal(StackActions.Push)
expect(StackActions.Reset).to.equal(StackActions.Reset)
expect(StackActions.Replace).to.equal(StackActions.Replace)
end)
it("should return matching string names for symbols", function()
expect(tostring(StackActions.Pop)).to.equal("POP")
expect(tostring(StackActions.PopToTop)).to.equal("POP_TO_TOP")
expect(tostring(StackActions.Push)).to.equal("PUSH")
expect(tostring(StackActions.Reset)).to.equal("RESET")
expect(tostring(StackActions.Replace)).to.equal("REPLACE")
end)
end)
describe("StackActions function tests", function()
it("should return a pop action for pop()", function()
local popTable = StackActions.pop({
n = "n",
})
expect(popTable.type).to.equal(StackActions.Pop)
expect(popTable.n).to.equal("n")
end)
it("should return a pop to top action for popToTop()", function()
local popToTopTable = StackActions.popToTop()
expect(popToTopTable.type).to.equal(StackActions.PopToTop)
end)
it("should return a push action for push()", function()
local pushTable = StackActions.push({
routeName = "routeName",
params = "params",
action = "action",
})
expect(pushTable.type).to.equal(StackActions.Push)
expect(pushTable.routeName).to.equal("routeName")
expect(pushTable.params).to.equal("params")
expect(pushTable.action).to.equal("action")
end)
it("should return a reset action for reset()", function()
local resetTable = StackActions.reset({
index = "index",
actions = "actions",
key = "key",
})
expect(resetTable.type).to.equal(StackActions.Reset)
expect(resetTable.index).to.equal("index")
expect(resetTable.key).to.equal("key")
end)
it("should return a replace action for replace()", function()
local replaceTable = StackActions.replace({
key = "key",
newKey = "newKey",
routeName = "routeName",
params = "params",
action = "action",
immediate = "immediate",
})
expect(replaceTable.type).to.equal(StackActions.Replace)
expect(replaceTable.key).to.equal("key")
expect(replaceTable.newKey).to.equal("newKey")
expect(replaceTable.routeName).to.equal("routeName")
expect(replaceTable.params).to.equal("params")
expect(replaceTable.action).to.equal("action")
expect(replaceTable.immediate).to.equal("immediate")
end)
end)
end
@@ -0,0 +1,725 @@
return function()
local StateUtils = require(script.Parent.Parent.StateUtils)
describe("StateUtils.get tests", function()
it("should assert if state is not a table", function()
expect(function()
StateUtils.get(nil, "key")
end).to.throw()
end)
it("should assert if key is not a string", function()
expect(function()
StateUtils.get({}, 5)
end).to.throw()
end)
it("should return nil if key is not found in routes", function()
local result = StateUtils.get({
index = 1,
routes = {
{
routeName = "foo",
key = "foo-1",
},
},
}, "key")
expect(result).to.equal(nil)
end)
it("should return route if key is found in routes", function()
local result = StateUtils.get({
index = 1,
routes = {
{
routeName = "foo",
key = "foo-1",
}
},
}, "foo-1")
expect(result.routeName).to.equal("foo")
expect(result.key).to.equal("foo-1")
end)
end)
describe("StateUtils.getAtIndex tests", function()
it("should assert if state is not a table", function()
expect(function()
StateUtils.getAtIndex(nil, 0)
end).to.throw()
end)
it("should assert if index is negative", function()
expect(function()
StateUtils.getAtIndex({}, -1)
end).to.throw()
end)
it("should return nil if index is not found", function()
local result = StateUtils.getAtIndex({
index = 1,
routes = {
{
routeName = "foo1",
key = "foo-1",
},
{
routeName = "foo2",
key = "foo-2",
}
}
}, 5)
expect(result).to.equal(nil)
end)
it("should return a matching route", function()
local result = StateUtils.getAtIndex({
index = 1,
routes = {
{
routeName = "foo1",
key = "foo-1",
},
{
routeName = "foo2",
key = "foo-2",
}
}
}, 2)
expect(result.routeName).to.equal("foo2")
expect(result.key).to.equal("foo-2")
end)
end)
describe("StateUtils.getActiveRoute tests", function()
it("should assert if state is not a table", function()
expect(function()
StateUtils.getActiveRoute(nil)
end).to.throw()
end)
it("should return nil if no routes", function()
local result = StateUtils.getActiveRoute({
index = 0,
routes = {},
})
expect(result).to.equal(nil)
end)
it("should return active route", function()
local result = StateUtils.getActiveRoute({
index = 1,
routes = {
{
routeName = "active",
key = "active-1",
}
},
})
expect(result.routeName).to.equal("active")
expect(result.key).to.equal("active-1")
end)
end)
describe("StateUtils.indexOf tests", function()
it("should assert if state is not a table", function()
expect(function()
StateUtils.indexOf(nil, "key")
end).to.throw()
end)
it("should assert if key is not a string", function()
expect(function()
StateUtils.indexOf({}, 5)
end).to.throw()
end)
it("should return nil if key is not found in routes", function()
local result = StateUtils.indexOf({
index = 1,
routes = {
{
routeName = "foo",
key = "foo-1",
}
},
}, "key")
expect(result).to.equal(nil)
end)
it("should return index if key is found in routes", function()
local result = StateUtils.indexOf({
index = 1,
routes = {
{
routeName = "foo",
key = "foo-1",
},
{
routeName = "foo2",
key = "foo-2",
}
},
}, "foo-2")
expect(result).to.equal(2)
end)
end)
describe("StateUtils.has tests", function()
it("should assert if state is not a table", function()
expect(function()
StateUtils.has(nil, "key")
end).to.throw()
end)
it("should assert if key is not a string", function()
expect(function()
StateUtils.has({}, 5)
end).to.throw()
end)
it("should return false if key is not in routes", function()
local result = StateUtils.has({
index = 1,
routes = {
{
routeName = "foo",
key = "foo-1",
}
}
}, "key")
expect(result).to.equal(false)
end)
it("should return true if key is found in routes", function()
local result = StateUtils.has({
index = 1,
routes = {
{
routeName = "foo",
key = "foo-1",
}
}
}, "foo-1")
expect(result).to.equal(true)
end)
end)
describe("StateUtils.push tests", function()
it("should assert if state is not a table", function()
expect(function()
StateUtils.push(nil, {})
end).to.throw()
end)
it("should assert if route is not a table", function()
expect(function()
StateUtils.push({}, 5)
end).to.throw()
end)
it("should assert if route.key is already present", function()
expect(function()
StateUtils.push({
index = 1,
routes = {
{
routeName = "foo",
key = "foo-1",
}
}
}, {
routeName = "foo",
key = "foo-1",
})
end).to.throw()
end)
it("should insert new route if it doesn't exist", function()
local newState = StateUtils.push({
index = 1,
routes = {
{
routeName = "first",
key = "foo-1",
},
},
}, {
routeName = "second",
key = "foo-2",
})
expect(newState.index).to.equal(2)
expect(#newState.routes).to.equal(2)
expect(newState.routes[newState.index].key).to.equal("foo-2")
expect(newState.routes[newState.index].routeName).to.equal("second")
end)
end)
describe("StateUtils.pop tests", function()
it("should assert if state is not a table", function()
expect(function()
StateUtils.pop(nil)
end).to.throw()
end)
it("should return existing state if routes is empty", function()
local initialState = {
index = 0,
routes = {},
}
local newState = StateUtils.pop(initialState)
expect(newState).to.equal(initialState)
end)
it("should return empty state if popping one route", function()
local initialState = {
index = 1,
routes = {
{ routeName = "route", key = "route-1", },
},
}
local newState = StateUtils.pop(initialState)
expect(newState.index).to.equal(0)
expect(#newState.routes).to.equal(0)
end)
it("should remove top route if popping with more than one route", function()
local initialState = {
index = 2,
routes = {
{ routeName = "route", key = "route-1", },
{ routeName = "route", key = "route-2", },
},
}
local newState = StateUtils.pop(initialState)
expect(newState.index).to.equal(1)
expect(#newState.routes).to.equal(1)
expect(newState.routes[1].key).to.equal("route-1")
end)
end)
describe("StateUtils.jumpToIndex tests", function()
it("should assert if state is not a table", function()
expect(function()
StateUtils.jumpToIndex(nil, 0)
end).to.throw()
end)
it("should assert if index is not a number", function()
expect(function()
StateUtils.jumpToIndex({}, "foo")
end).to.throw()
end)
it("should assert if index does not match a route", function()
expect(function()
StateUtils.jumpToIndex({
index = 1,
routes = { { routeName = "first", key = "first-1" } }
}, 5)
end).to.throw()
end)
it("should return original state if index matches current", function()
local initialState = {
index = 1,
routes = { { routeName = "one", key = "1" } }
}
local newState = StateUtils.jumpToIndex(initialState, 1)
expect(newState).to.equal(initialState)
end)
it("should return updated state if index differs", function()
local initialState = {
index = 1,
routes = {
{ routeName = "route", key = "route-1" },
{ routeName = "route", key = "route-2" },
},
}
local newState = StateUtils.jumpToIndex(initialState, 2)
expect(newState.index).to.equal(2)
end)
end)
describe("StateUtils.jumpTo tests", function()
it("should assert if state is not a table", function()
expect(function()
StateUtils.jumpTo(nil, "key")
end).to.throw()
end)
it("should assert if key is not a string", function()
expect(function()
StateUtils.jumpTo({}, 0)
end).to.throw()
end)
it("should return original state if key is already active route", function()
local initialState = {
index = 1,
routes = {
{ routeName = "route", key = "key-1" },
{ routeName = "route", key = "key-2" },
}
}
local newState = StateUtils.jumpTo(initialState, "key-1")
expect(newState).to.equal(initialState)
end)
it("should return state with new active route if key is not active", function()
local initialState = {
index = 1,
routes = {
{ routeName = "route", key = "key-1" },
{ routeName = "route", key = "key-2" },
}
}
local newState = StateUtils.jumpTo(initialState, "key-2")
expect(newState.index).to.equal(2)
end)
end)
describe("StateUtils.back tests", function()
it("should assert if state is not a table", function()
expect(function()
StateUtils.back(nil)
end).to.throw()
end)
it("should return original state if route for new index does not exist", function()
local initialState = {
index = 1,
routes = {
{ routeName = "route", key = "key-1" },
}
}
local newState = StateUtils.back(initialState)
expect(newState).to.equal(initialState)
end)
it("should remove top state if there is somewhere to go", function()
local initialState = {
index = 2,
routes = {
{ routeName = "route", key = "key-1" },
{ routeName = "route", key = "key-2" },
}
}
local newState = StateUtils.back(initialState)
expect(newState.index).to.equal(1)
end)
end)
describe("StateUtils.forward tests", function()
it("should assert if state is not a table", function()
expect(function()
StateUtils.forward(nil)
end).to.throw()
end)
it("should not walk off the end of the route list", function()
local initialState = {
index = 1,
routes = {
{ routeName = "route", key = "key-1" },
}
}
local newState = StateUtils.forward(initialState)
expect(newState).to.equal(initialState)
end)
it("should move to next route if available", function()
local initialState = {
index = 1,
routes = {
{ routeName = "route", key = "key-1" },
{ routeName = "route", key = "key-2" },
}
}
local newState = StateUtils.forward(initialState)
expect(newState.index).to.equal(2)
end)
end)
describe("StateUtils.replaceAndPrune tests", function()
it("should assert if state is not a table", function()
expect(function()
StateUtils.replaceAndPrune(nil, "key", {})
end).to.throw()
end)
it("should assert if key is not a string", function()
expect(function()
StateUtils.replaceAndPrune({}, 0, {})
end).to.throw()
end)
it("should assert if route is not a table", function()
expect(function()
StateUtils.replaceAndPrune({}, "key", 0)
end).to.throw()
end)
it("should replace matching route and prune following routes", function()
local initialState = {
index = 2,
routes = {
{ routeName = "route", key = "key-1" },
{ routeName = "route", key = "key-2" },
}
}
local newState = StateUtils.replaceAndPrune(initialState, "key-1", {
routeName = "newRoute", key = "key-3"
})
expect(newState.index).to.equal(1)
expect(#newState.routes).to.equal(1)
expect(newState.routes[1].routeName).to.equal("newRoute")
expect(newState.routes[1].key).to.equal("key-3")
end)
end)
describe("StateUtils.replaceAt tests", function()
it("should assert if state is not a table", function()
expect(function()
StateUtils.replaceAt(nil, "key", {}, false)
end).to.throw()
end)
it("should assert if key is not a string", function()
expect(function()
StateUtils.replaceAt({}, 0, {}, false)
end).to.throw()
end)
it("should assert if route is not a table", function()
expect(function()
StateUtils.replaceAt({}, "key", 0, false)
end).to.throw()
end)
it("should assert if preserveIndex is not a boolean", function()
expect(function()
StateUtils.replaceAt({}, "key", {}, 0)
end).to.throw()
end)
it("should replace matching route, not prune, and update index", function()
local initialState = {
index = 2,
routes = {
{ routeName = "route", key = "key-1" },
{ routeName = "route", key = "key-2" },
}
}
local newState = StateUtils.replaceAt(initialState, "key-1", {
routeName = "newRoute", key = "key-3"
}, false)
expect(newState.index).to.equal(1)
expect(#newState.routes).to.equal(2)
expect(newState.routes[1].routeName).to.equal("newRoute")
expect(newState.routes[1].key).to.equal("key-3")
end)
it("should replace matching route, not prune, and preserve existing index", function()
local initialState = {
index = 2,
routes = {
{ routeName = "route", key = "key-1" },
{ routeName = "route", key = "key-2" },
}
}
local newState = StateUtils.replaceAt(initialState, "key-1", {
routeName = "newRoute", key = "key-3"
}, true)
expect(newState.index).to.equal(2)
expect(#newState.routes).to.equal(2)
expect(newState.routes[1].routeName).to.equal("newRoute")
expect(newState.routes[1].key).to.equal("key-3")
end)
end)
describe("StateUtils.replaceAtIndex tests", function()
it("should assert if state is not a table", function()
expect(function()
StateUtils.replaceAtIndex(nil, 0, {})
end).to.throw()
end)
it("should assert if index is not a number", function()
expect(function()
StateUtils.replaceAtIndex({}, nil, {})
end).to.throw()
end)
it("should assert if route is not a table", function()
expect(function()
StateUtils.replaceAtIndex({}, 5, nil)
end).to.throw()
end)
it("should assert if index does not exist", function()
expect(function()
StateUtils.replaceAtIndex({
index = 0,
routes = {}
}, 5, { routeName = "name", key = "key" })
end).to.throw()
end)
it("should return original state if inputs are same", function()
local testRoute = { routeName = "name", key = "key" }
local initialState = {
index = 1,
routes = { testRoute },
}
local newState = StateUtils.replaceAtIndex(initialState, 1, testRoute)
expect(newState).to.equal(initialState)
end)
it("should replace route at index if route is not equal", function()
local initialState = {
index = 1,
routes = {
{ routeName = "name", key = "key" }
},
}
local newState = StateUtils.replaceAtIndex(initialState, 1, {
routeName = "newName",
key = "key",
})
expect(newState.index).to.equal(1)
expect(#newState.routes).to.equal(1)
expect(newState.routes[1].routeName).to.equal("newName")
expect(newState.routes[1].key).to.equal("key")
end)
it("should update index, if new index differs but route does not", function()
local testRoute = { routeName = "name", key = "key-2" }
local initialState = {
index = 1,
routes = {
{ routeName = "name", key = "key-1" },
testRoute,
}
}
local newState = StateUtils.replaceAtIndex(initialState, 2, testRoute)
expect(newState).never.to.equal(initialState)
expect(newState.index).to.equal(2)
end)
end)
describe("StateUtils.reset tests", function()
it("should assert if state is not a table", function()
expect(function()
StateUtils.reset(nil, {}, 0)
end).to.throw()
end)
it("should assert if routes is not a table", function()
expect(function()
StateUtils.reset({}, nil, 0)
end).to.throw()
end)
it("should assert if index is not a number", function()
expect(function()
StateUtils.reset({}, {}, "foo")
end).to.throw()
end)
it("should NOT assert if index is nil", function()
expect(function()
StateUtils.reset({}, {})
end).to.throw()
end)
it("should return original state if index matches and all routes are same objects", function()
local route1 = { routeName = "route1", key = "route-1" }
local route2 = { routeName = "route2", key = "route-2" }
local initialState = {
index = 2,
routes = { route1, route2 },
}
local newState = StateUtils.reset(initialState, {
route1,
route2,
}, 2)
expect(newState).to.equal(initialState)
end)
it("should update state if index is not specified and old index is not last route", function()
local route1 = { routeName = "route1", key = "route-1" }
local route2 = { routeName = "route2", key = "route-2" }
local initialState = {
index = 1,
routes = { route1, route2 },
}
local newState = StateUtils.reset(initialState, {
route1,
route2,
})
expect(newState).never.to.equal(initialState)
expect(newState.index).to.equal(2)
end)
it("should update state if index matches but routes differ", function()
local route1 = { routeName = "route1", key = "route-1" }
local route2 = { routeName = "route2", key = "route-2" }
local initialState = {
index = 1,
routes = { route1, route2 },
}
local newState = StateUtils.reset(initialState, {
route1,
{ routeName = "route3", key = "route-3" },
}, 1)
expect(newState).never.to.equal(initialState)
expect(#newState.routes).to.equal(2)
expect(newState.index).to.equal(1)
expect(newState.routes[2].routeName).to.equal("route3")
expect(newState.routes[2].key).to.equal("route-3")
end)
end)
end
@@ -0,0 +1,143 @@
return function()
local Roact = require(script.Parent.Parent.Parent.Roact)
local NavigationActions = require(script.Parent.Parent.NavigationActions)
local createAppContainer = require(script.Parent.Parent.createAppContainer)
local createSwitchNavigator = require(script.Parent.Parent.navigators.createSwitchNavigator)
it("should be a function", function()
expect(type(createAppContainer)).to.equal("function")
end)
it("should return a valid component when mounting a switch navigator", function()
local TestNavigator = createSwitchNavigator({
routes = {
Foo = function() end,
},
initialRouteName = "Foo",
})
local TestApp = createAppContainer(TestNavigator)
local element = Roact.createElement(TestApp)
local instance = Roact.mount(element)
Roact.unmount(instance)
end)
it("should throw when navigator has both navigation and container props", function()
local TestAppComponent = Roact.Component:extend("TestAppComponent")
TestAppComponent.router = {}
function TestAppComponent:render() end
local element = Roact.createElement(createAppContainer(TestAppComponent), {
navigation = {},
somePropThatShouldNotBeHere = true,
})
local status, err = pcall(function()
Roact.mount(element)
end)
expect(status).to.equal(false)
expect(string.find(err, "This navigator has both 'navigation' and container props.")).to.never.equal(nil)
end)
it("should throw when not passed a table for AppComponent", function()
local TestAppComponent = 5
local status, err = pcall(function()
createAppContainer(TestAppComponent)
end)
expect(status).to.equal(false)
expect(string.find(err, "AppComponent must be a navigator or a stateful Roact " ..
"component with a 'router' field")).to.never.equal(nil)
end)
it("should throw when passed a stateful component without router field", function()
local TestAppComponent = Roact.Component:extend("TestAppComponent")
local status, err = pcall(function()
createAppContainer(TestAppComponent)
end)
expect(status).to.equal(false)
expect(string.find(err, "AppComponent must be a navigator or a stateful Roact " ..
"component with a 'router' field")).to.never.equal(nil)
end)
it("should accept actions from externalDispatchConnector", function()
local TestNavigator = createSwitchNavigator({
routes = {
Foo = function() end,
},
initialRouteName = "Foo",
})
local registeredCallback = nil
local externalDispatchConnector = function(rnCallback)
registeredCallback = rnCallback
return function()
registeredCallback = nil
end
end
local element = Roact.createElement(createAppContainer(TestNavigator), {
externalDispatchConnector = externalDispatchConnector,
})
local instance = Roact.mount(element)
expect(type(registeredCallback)).to.equal("function")
-- Make sure it processes action
local result = registeredCallback(NavigationActions.navigate({
routeName = "Foo",
}))
expect(result).to.equal(true)
local failResult = registeredCallback(NavigationActions.navigate({
routeName = "Bar", -- should fail because not a valid route
}))
expect(failResult).to.equal(false)
Roact.unmount(instance)
expect(registeredCallback).to.equal(nil)
end)
it("should correctly pass screenProps to pages", function()
local passedScreenProps = nil
local extractedValue1 = nil
local extractedMissingValue1 = nil
local extractedMissingValue2 = nil
local testScreenProps = {
MyKey1 = "MyValue1",
}
local TestNavigator = createSwitchNavigator({
routes = {
Foo = function(props)
-- doing this in render is an abuse, but it's just a test
passedScreenProps = props.navigation.getScreenProps()
extractedValue1 = props.navigation.getScreenProps("MyKey1")
extractedMissingValue1 = props.navigation.getScreenProps("MyMissingKey", 5)
extractedMissingValue2 = props.navigation.getScreenProps("MyMissingKey")
end,
},
initialRouteName = "Foo",
})
local TestApp = createAppContainer(TestNavigator)
local element = Roact.createElement(TestApp, {
screenProps = testScreenProps,
})
local instance = Roact.mount(element)
expect(passedScreenProps).to.equal(testScreenProps)
expect(extractedValue1).to.equal("MyValue1")
expect(extractedMissingValue1).to.equal(5)
expect(extractedMissingValue2).to.equal(nil)
Roact.unmount(instance)
end)
end
@@ -0,0 +1,498 @@
return function()
local NavigationEvents = require(script.Parent.Parent.NavigationEvents)
local getChildEventSubscriber = require(script.Parent.Parent.getChildEventSubscriber)
local function dummyAddListener() end
local function makeListenerBundle()
local testUpstreamListenerMap = {}
local function testAddUpstreamListener(eventType, callback)
testUpstreamListenerMap[eventType] = callback
return {
disconnect = function()
testUpstreamListenerMap[eventType] = nil
end
}
end
return {
listenerMap = testUpstreamListenerMap,
addListener = testAddUpstreamListener,
}
end
local SIMPLE_TEST_KEY = "Foo"
local SIMPLE_TEST_STATE = {
state = {
routes = {
{ key = SIMPLE_TEST_KEY }
},
index = 1,
},
lastState = {
routes = {
{ key = SIMPLE_TEST_KEY }
},
index = 1,
},
action = {
type = "SomeAction"
},
}
it("should return a table with correct members", function()
local childSubscriber = getChildEventSubscriber(dummyAddListener, SIMPLE_TEST_KEY)
expect(type(childSubscriber.addListener)).to.equal("function")
expect(type(childSubscriber.emit)).to.equal("function")
end)
describe("addListener tests", function()
it("should throw on invalid eventType", function()
local childSubscriber = getChildEventSubscriber(dummyAddListener, SIMPLE_TEST_KEY)
expect(function()
childSubscriber.addListener("BadSymbol", function() end)
end).to.throw()
end)
it("should throw on invalid eventHandler", function()
local childSubscriber = getChildEventSubscriber(dummyAddListener, SIMPLE_TEST_KEY)
expect(function()
childSubscriber.addListener(NavigationEvents.Action, 5)
end).to.throw()
end)
it("should allow disconnect of listener", function()
local childSubscriber = getChildEventSubscriber(dummyAddListener, SIMPLE_TEST_KEY)
local connection = childSubscriber.addListener(NavigationEvents.Refocus, function() end)
connection.disconnect()
end)
end)
describe("emit tests", function()
it("should throw when trying to emit any event besides Refocus", function()
local childSubscriber = getChildEventSubscriber(dummyAddListener, SIMPLE_TEST_KEY)
expect(function()
childSubscriber.emit(NavigationEvents.WillFocus)
end).to.throw()
expect(function()
childSubscriber.emit(NavigationEvents.DidFocus)
end).to.throw()
expect(function()
childSubscriber.emit(NavigationEvents.WillBlur)
end).to.throw()
expect(function()
childSubscriber.emit(NavigationEvents.DidBlur)
end).to.throw()
expect(function()
childSubscriber.emit(NavigationEvents.Action)
end).to.throw()
end)
it("should throw when payload is not a table", function()
local childSubscriber = getChildEventSubscriber(dummyAddListener, SIMPLE_TEST_KEY)
expect(function()
childSubscriber.emit(NavigationEvents.Refocus, 5)
end).to.throw()
end)
it("should allow external caller to emit a refocus event with valid payload", function()
local childSubscriber = getChildEventSubscriber(dummyAddListener, SIMPLE_TEST_KEY)
local testPayload = { a = 1 }
local outputPayload = nil
childSubscriber.addListener(NavigationEvents.Refocus, function(payload)
outputPayload = payload
end)
childSubscriber.emit(NavigationEvents.Refocus, testPayload)
expect(outputPayload.a).to.equal(1)
expect(outputPayload.type).to.equal(NavigationEvents.Refocus)
end)
it("should allow external caller to emit a refocus event with nil payload", function()
local childSubscriber = getChildEventSubscriber(dummyAddListener, SIMPLE_TEST_KEY)
local outputPayload = nil
childSubscriber.addListener(NavigationEvents.Refocus, function(payload)
outputPayload = payload
end)
childSubscriber.emit(NavigationEvents.Refocus)
expect(outputPayload.type).to.equal(NavigationEvents.Refocus)
end)
end)
describe("upstream event handling tests", function()
it("should register subscriptions for supported event types", function()
local testUpstreamListenerMap = {}
local function testAddUpstreamListener(eventType, callback)
expect(testUpstreamListenerMap[eventType]).to.equal(nil)
testUpstreamListenerMap[eventType] = true
return {
disconnect = function() end
}
end
getChildEventSubscriber(testAddUpstreamListener, SIMPLE_TEST_KEY)
expect(testUpstreamListenerMap[NavigationEvents.Action]).to.equal(true)
expect(testUpstreamListenerMap[NavigationEvents.WillFocus]).to.equal(true)
expect(testUpstreamListenerMap[NavigationEvents.DidFocus]).to.equal(true)
expect(testUpstreamListenerMap[NavigationEvents.WillBlur]).to.equal(true)
expect(testUpstreamListenerMap[NavigationEvents.DidBlur]).to.equal(true)
expect(testUpstreamListenerMap[NavigationEvents.Refocus]).to.equal(true)
end)
it("should disconnect subscriptions on DidBlur when there is no new route", function()
local testUpstreamListenerMap = {}
local function testAddUpstreamListener(eventType, callback)
testUpstreamListenerMap[eventType] = callback
return {
disconnect = function()
testUpstreamListenerMap[eventType] = false
end
}
end
local childSubscriber = getChildEventSubscriber(
testAddUpstreamListener, SIMPLE_TEST_KEY, "Blurred")
childSubscriber.addListener(NavigationEvents.Action, function() end)
testUpstreamListenerMap[NavigationEvents.DidBlur]({
state = {},
action = {
type = "SomeAction"
}
})
expect(testUpstreamListenerMap[NavigationEvents.Action]).to.equal(false)
expect(testUpstreamListenerMap[NavigationEvents.WillFocus]).to.equal(false)
expect(testUpstreamListenerMap[NavigationEvents.DidFocus]).to.equal(false)
expect(testUpstreamListenerMap[NavigationEvents.WillBlur]).to.equal(false)
expect(testUpstreamListenerMap[NavigationEvents.DidBlur]).to.equal(false)
expect(testUpstreamListenerMap[NavigationEvents.Refocus]).to.equal(false)
end)
it("should NOT disconnect subscriptions on DidBlur when there is a new route", function()
local testUpstreamListenerMap = {}
local function testAddUpstreamListener(eventType, callback)
testUpstreamListenerMap[eventType] = callback
return {
disconnect = function()
testUpstreamListenerMap[eventType] = false
end
}
end
local childSubscriber = getChildEventSubscriber(testAddUpstreamListener, SIMPLE_TEST_KEY, "Blurred")
childSubscriber.addListener(NavigationEvents.Action, function() end)
testUpstreamListenerMap[NavigationEvents.DidBlur](SIMPLE_TEST_STATE)
expect(testUpstreamListenerMap[NavigationEvents.Action]).to.never.equal(false)
expect(testUpstreamListenerMap[NavigationEvents.WillFocus]).to.never.equal(false)
expect(testUpstreamListenerMap[NavigationEvents.DidFocus]).to.never.equal(false)
expect(testUpstreamListenerMap[NavigationEvents.WillBlur]).to.never.equal(false)
expect(testUpstreamListenerMap[NavigationEvents.DidBlur]).to.never.equal(false)
expect(testUpstreamListenerMap[NavigationEvents.Refocus]).to.never.equal(false)
end)
it("should propagate refocus event from upstream", function()
local bundle = makeListenerBundle()
local outputPayload = nil
local childSubscriber = getChildEventSubscriber(bundle.addListener, SIMPLE_TEST_KEY)
childSubscriber.addListener(NavigationEvents.Refocus, function(payload)
outputPayload = payload
end)
bundle.listenerMap[NavigationEvents.Refocus]({ a = 1 })
expect(outputPayload.a).to.equal(1)
expect(outputPayload.type).to.equal(NavigationEvents.Refocus)
end)
it("should emit WillFocus on WillFocus event when previously blurred and child is current index", function()
local bundle = makeListenerBundle()
local childSubscriber = getChildEventSubscriber(bundle.addListener, SIMPLE_TEST_KEY)
local willFocusPayload = nil
childSubscriber.addListener(NavigationEvents.WillFocus, function(payload)
willFocusPayload = payload
end)
bundle.listenerMap[NavigationEvents.WillFocus](SIMPLE_TEST_STATE)
-- Detailed analysis of generated payload. Further tests will just check that functor was called.
expect(willFocusPayload).to.never.equal(nil)
expect(willFocusPayload.state).to.never.equal(nil)
expect(willFocusPayload.lastState).to.never.equal(nil)
expect(willFocusPayload.action.type).to.equal("SomeAction")
expect(willFocusPayload.type).to.equal(NavigationEvents.WillFocus)
end)
it("should emit WillFocus AND DidFocus on Action event when previously blurred and child is current index", function()
local bundle = makeListenerBundle()
local childSubscriber = getChildEventSubscriber(bundle.addListener, SIMPLE_TEST_KEY)
local willFocusCalled = false
childSubscriber.addListener(NavigationEvents.WillFocus, function()
willFocusCalled = true
end)
local didFocusCalled = false
childSubscriber.addListener(NavigationEvents.DidFocus, function()
didFocusCalled = true
end)
bundle.listenerMap[NavigationEvents.Action](SIMPLE_TEST_STATE)
expect(willFocusCalled).to.equal(true)
expect(didFocusCalled).to.equal(true)
end)
it("should NOT emit WillFocus or DidFocus on Action event when previously blurred and child is NOT current index",
function()
local bundle = makeListenerBundle()
local childSubscriber = getChildEventSubscriber(bundle.addListener, SIMPLE_TEST_KEY)
local willFocusCalled = false
childSubscriber.addListener(NavigationEvents.WillFocus, function()
willFocusCalled = true
end)
local didFocusCalled = false
childSubscriber.addListener(NavigationEvents.DidFocus, function()
didFocusCalled = true
end)
bundle.listenerMap[NavigationEvents.Action]({
state = {
routes = {
{ key = SIMPLE_TEST_KEY },
{ key = "NOT_SIMPLE_TEST_KEY" },
},
index = 2,
},
action = {
"SomeAction"
},
})
expect(willFocusCalled).to.equal(false)
expect(didFocusCalled).to.equal(false)
end)
it("should emit DidFocus on DidFocus event when previous event was WillFocus and child is current index", function()
local bundle = makeListenerBundle()
local childSubscriber = getChildEventSubscriber(bundle.addListener, SIMPLE_TEST_KEY, "Focusing")
local didFocusCalled = false
childSubscriber.addListener(NavigationEvents.DidFocus, function()
didFocusCalled = true
end)
bundle.listenerMap[NavigationEvents.DidFocus](SIMPLE_TEST_STATE)
expect(didFocusCalled).to.equal(true)
end)
it("should emit DidFocus on Action event when previous event was WillFocus and child is current index", function()
local bundle = makeListenerBundle()
local childSubscriber = getChildEventSubscriber(bundle.addListener, SIMPLE_TEST_KEY, "Focusing")
local didFocusCalled = false
childSubscriber.addListener(NavigationEvents.DidFocus, function()
didFocusCalled = true
end)
bundle.listenerMap[NavigationEvents.Action](SIMPLE_TEST_STATE)
expect(didFocusCalled).to.equal(true)
end)
it("should NOT emit DidFocus on DidFocus event when previous event was WillFocus while transitioning", function()
local bundle = makeListenerBundle()
local childSubscriber = getChildEventSubscriber(bundle.addListener, SIMPLE_TEST_KEY, "Focusing")
local didFocusCalled = false
childSubscriber.addListener(NavigationEvents.DidFocus, function()
didFocusCalled = true
end)
bundle.listenerMap[NavigationEvents.DidFocus]({
state = {
routes = {
{ key = SIMPLE_TEST_KEY }
},
index = 1,
isTransitioning = true,
},
action = {
"SomeAction"
},
})
expect(didFocusCalled).to.equal(false)
end)
it("should emit WillBlur on WillBlur event when previous event was DidFocus", function()
local bundle = makeListenerBundle()
local childSubscriber = getChildEventSubscriber(bundle.addListener, SIMPLE_TEST_KEY, "Focused")
local willBlurCalled = false
childSubscriber.addListener(NavigationEvents.WillBlur, function()
willBlurCalled = true
end)
bundle.listenerMap[NavigationEvents.WillBlur](SIMPLE_TEST_STATE)
expect(willBlurCalled).to.equal(true)
end)
it("should emit Action on Action event when previous event was DidFocus", function()
local bundle = makeListenerBundle()
local childSubscriber = getChildEventSubscriber(bundle.addListener, SIMPLE_TEST_KEY, "Focused")
local actionCalled = false
childSubscriber.addListener(NavigationEvents.Action, function()
actionCalled = true
end)
bundle.listenerMap[NavigationEvents.Action](SIMPLE_TEST_STATE)
expect(actionCalled).to.equal(true)
end)
it("should emit DidBlur on DidBlur event when previous event was WillBlur", function()
local bundle = makeListenerBundle()
local childSubscriber = getChildEventSubscriber(bundle.addListener, SIMPLE_TEST_KEY, "Blurring")
local didBlurCalled = false
childSubscriber.addListener(NavigationEvents.DidBlur, function()
didBlurCalled = true
end)
bundle.listenerMap[NavigationEvents.DidBlur](SIMPLE_TEST_STATE)
expect(didBlurCalled).to.equal(true)
end)
it("should emit DidBlur on Action event when previous event was WillBlur and we've finished transitioning", function()
local bundle = makeListenerBundle()
local childSubscriber = getChildEventSubscriber(bundle.addListener, "Foo", "Blurring")
local didBlurCalled = false
childSubscriber.addListener(NavigationEvents.DidBlur, function()
didBlurCalled = true
end)
bundle.listenerMap[NavigationEvents.Action]({
state = {
routes = {
{ key = "Foo" }, -- Transitioned away from this route!
{ key = "Bar" },
},
index = 2,
},
lastState = {
routes = {
{ key = "Foo" },
{ key = "Bar" },
},
index = 1,
},
action = {
type = "SomeAction"
},
})
expect(didBlurCalled).to.equal(true)
end)
it("should emit WillFocus on Action event when previois event was WillBlur, while transitioning to child", function()
local bundle = makeListenerBundle()
local childSubscriber = getChildEventSubscriber(bundle.addListener, "Bar", "Blurring")
local willFocusCalled = false
childSubscriber.addListener(NavigationEvents.WillFocus, function()
willFocusCalled = true
end)
bundle.listenerMap[NavigationEvents.Action]({
state = {
routes = {
{ key = "Foo" }, -- Transitioned away from this route!
{ key = "Bar" },
},
index = 2,
isTransitioning = true,
},
lastState = {
routes = {
{ key = "Foo" },
{ key = "Bar" },
},
index = 1,
},
action = {
type = "SomeAction"
},
})
expect(willFocusCalled).to.equal(true)
end)
it("should disconnect from input events after finalizing blur if removed from nav state", function()
local bundle = makeListenerBundle()
local childSubscriber = getChildEventSubscriber(bundle.addListener, "Bar", "Blurring")
local didBlurCalled = false
childSubscriber.addListener(NavigationEvents.DidBlur, function()
didBlurCalled = true
end)
bundle.listenerMap[NavigationEvents.Action]({
state = {
routes = {
{ key = "Foo" },
},
index = 1,
isTransitioning = false,
},
lastState = {
routes = {
{ key = "Foo" },
{ key = "Bar" }, -- Transitioning away from this route.
},
index = 1,
isTransitioning = true,
},
action = {
type = "SomeAction"
},
})
expect(bundle.listenerMap[NavigationEvents.Action]).to.equal(nil)
expect(bundle.listenerMap[NavigationEvents.Refocus]).to.equal(nil)
expect(didBlurCalled).to.equal(true) -- Event should still be propagated!
end)
end)
end
@@ -0,0 +1,119 @@
return function()
local getChildNavigation = require(script.Parent.Parent.getChildNavigation)
it("should return nil if there is no route matching requested key", function()
local testNavigation = {
state = {
routes = {
{ key = "a" }
}
}
}
local childNav = getChildNavigation(testNavigation, "invalid_child", function()
return testNavigation
end)
expect(childNav).to.equal(nil)
end)
it("should return cached child if its state is a top-level route", function()
local testNavigation = {
state = {
routes = {
{ key = "a" }
},
},
}
testNavigation._childrenNavigation = {
a = {
state = testNavigation.state.routes[1]
}
}
local childNav = getChildNavigation(testNavigation, "a", function()
return testNavigation
end)
expect(childNav).to.equal(testNavigation._childrenNavigation.a)
end)
it("should update cache and return new data when child's state has changed", function()
local testNavigation = {
state = {
routes = {
{ key = "a", routeName = "a" },
{ key = "b", routeName = "b" },
},
index = 1,
},
router = {
getComponentForRouteName = function(routeName)
return function() end
end,
getActionCreators = function() end
}
}
local oldStateA = {
state = {
routes = {
{ key = "a", routeName = "a" },
{ key = "b", routeName = "b" },
},
index = 2,
},
}
testNavigation._childrenNavigation = {
a = oldStateA
}
local childNav = getChildNavigation(testNavigation, "a", function()
return testNavigation
end)
expect(childNav).to.equal(testNavigation._childrenNavigation["a"])
expect(childNav.state).to.equal(testNavigation.state.routes[1])
expect(type(childNav.getParam)).to.equal("function")
end)
it("should create a new entry if cached child does not exist yet", function()
local testNavigation = {
state = {
routes = {
{ key = "a", routeName = "a", params = { a = 1 } },
{ key = "b", routeName = "b" },
},
index = 1,
},
router = {
getComponentForRouteName = function(routeName)
return function() end
end,
getActionCreators = function() end,
},
addListener = function()
return {
disconnect = function() end
}
end,
isFocused = function()
return true
end,
}
local childNav = getChildNavigation(testNavigation, "a", function()
return testNavigation
end)
expect(testNavigation._childrenNavigation["a"]).to.never.equal(nil)
expect(childNav).to.equal(testNavigation._childrenNavigation["a"])
expect(childNav.isFocused()).to.equal(true)
expect(childNav.getParam("a", 0)).to.equal(1)
expect(childNav.getParam("b", 0)).to.equal(0)
end)
end
@@ -0,0 +1,63 @@
return function()
local getChildrenNavigationCache = require(script.Parent.Parent.getChildrenNavigationCache)
it("should return empty table if navigation arg not provided", function()
expect(getChildrenNavigationCache()._childrenNavigation).to.equal(nil)
end)
it("should populate navigation._childrenNavigation as a side-effect", function()
local navigation = { state = {} }
local result = getChildrenNavigationCache(navigation)
expect(result).to.never.equal(nil)
expect(navigation._childrenNavigation).to.equal(result)
end)
it("should delete children cache keys that are no longer valid", function()
local navigation = {
state = {
routes = {
{ key = "one" },
{ key = "two" },
{ key = "three" },
}
},
_childrenNavigation = {
one = {},
two = {},
three = {},
four = {},
}
}
local result = getChildrenNavigationCache(navigation)
expect(result.one).to.never.equal(nil)
expect(result.two).to.never.equal(nil)
expect(result.three).to.never.equal(nil)
expect(result.four).to.equal(nil)
end)
it("should not delete children cache keys if in transitioning state", function()
local navigation = {
state = {
routes = {
{ key = "one" },
{ key = "two" },
{ key = "three" },
},
isTransitioning = true,
},
_childrenNavigation = {
one = {},
two = {},
three = {},
four = {},
}
}
local result = getChildrenNavigationCache(navigation)
expect(result.one).to.never.equal(nil)
expect(result.two).to.never.equal(nil)
expect(result.three).to.never.equal(nil)
expect(result.four).to.never.equal(nil)
end)
end
@@ -0,0 +1,94 @@
return function()
local NavigationEvents = require(script.Parent.Parent.NavigationEvents)
local getNavigation = require(script.Parent.Parent.getNavigation)
local function makeTestBundle(testState)
testState = testState or {
routes = {
{ key = "a" }
},
index = 1,
}
local testActions = {}
local bundle = {
testActions = testActions,
testState = testState,
testRouter = {
getActionCreators = function()
return testActions
end
},
testDispatch = function() end,
testActionSubscribers = {},
testGetScreenProps = function() end,
}
function bundle.testGetCurrentNavigation()
return bundle.navigation
end
bundle.navigation = getNavigation(
bundle.testRouter,
bundle.testState,
bundle.testDispatch,
bundle.testActionSubscribers,
bundle.testGetScreenProps,
bundle.testGetCurrentNavigation
)
return bundle
end
it("should build out correct public props", function()
local bundle = makeTestBundle()
expect(bundle.navigation.actions).to.equal(bundle.testActions)
expect(bundle.navigation.router).to.equal(bundle.testRouter)
expect(bundle.navigation.state).to.equal(bundle.testState)
expect(bundle.navigation.dispatch).to.equal(bundle.testDispatch)
expect(bundle.navigation.getScreenProps).to.equal(bundle.testGetScreenProps)
expect(#bundle.navigation._childrenNavigation).to.equal(0)
end)
describe("isFocused tests", function()
it("should return focused=true for child key matching index", function()
local bundle = makeTestBundle()
expect(bundle.navigation.isFocused("a")).to.equal(true)
end)
it("should return focused=false for child key not matching index", function()
local bundle = makeTestBundle({
routes = {
{ key = "a" },
{ key = "b" },
},
index = 2,
})
expect(bundle.navigation.isFocused("a")).to.equal(false)
end)
it("should return focused=true if no child key provided (parent always focused)", function()
local bundle = makeTestBundle()
expect(bundle.navigation.isFocused()).to.equal(true)
end)
end)
describe("addListener tests", function()
it("should short-circuit subscriptions for non-Action events", function()
local bundle = makeTestBundle()
local testHandler = function() end
bundle.navigation.addListener(NavigationEvents.WillFocus, testHandler)
expect(bundle.testActionSubscribers[testHandler]).to.equal(nil)
end)
it("should add Action event handlers to actionSubscribers set", function()
local bundle = makeTestBundle()
local testHandler = function() end
bundle.navigation.addListener(NavigationEvents.Action, testHandler)
expect(bundle.testActionSubscribers[testHandler]).to.equal(true)
end)
end)
end
@@ -0,0 +1,156 @@
return function()
local RoactNavigation = require(script.Parent.Parent)
local Roact = require(script.Parent.Parent.Parent.Roact)
local StackPresentationStyle = require(script.Parent.Parent.views.StackView.StackPresentationStyle)
local NoneSymbol = require(script.Parent.Parent.NoneSymbol)
it("should load", function()
require(script.Parent.Parent)
end)
it("should return a function for createAppContainer", function()
expect(type(RoactNavigation.createAppContainer)).to.equal("function")
end)
it("should return a function for getNavigation", function()
expect(type(RoactNavigation.getNavigation)).to.equal("function")
end)
it("should return an appropriate table for Context", function()
expect(type(RoactNavigation.Context)).to.equal("table")
expect(type(RoactNavigation.Context.Provider)).to.equal("table")
expect(type(RoactNavigation.Context.Consumer)).to.equal("table")
expect(type(RoactNavigation.Context.connect)).to.equal("function")
end)
it("should return a table for Provider", function()
expect(type(RoactNavigation.Provider)).to.equal("table")
end)
it("should return a table for Consumer", function()
expect(type(RoactNavigation.Consumer)).to.equal("table")
end)
it("should return a function for connect", function()
expect(type(RoactNavigation.connect)).to.equal("function")
end)
it("should return a function for withNavigation", function()
expect(type(RoactNavigation.withNavigation)).to.equal("function")
end)
it("should return a function for withNavigationFocus", function()
expect(type(RoactNavigation.withNavigationFocus)).to.equal("function")
end)
it("should return a function for createSwitchNavigator", function()
expect(type(RoactNavigation.createSwitchNavigator)).to.equal("function")
end)
it("should return a function for createStackNavigator", function()
expect(type(RoactNavigation.createStackNavigator)).to.equal("function")
end)
it("should return a function for createNavigator", function()
expect(type(RoactNavigation.createNavigator)).to.equal("function")
end)
it("should return a function for StackRouter", function()
expect(type(RoactNavigation.StackRouter)).to.equal("function")
end)
it("should return a function for SwitchRouter", function()
expect(type(RoactNavigation.SwitchRouter)).to.equal("function")
end)
it("should return a function for TabRouter", function()
expect(type(RoactNavigation.TabRouter)).to.equal("function")
end)
it("should return a table for Actions", function()
expect(type(RoactNavigation.Actions)).to.equal("table")
end)
it("should return a table for StackActions", function()
expect(type(RoactNavigation.StackActions)).to.equal("table")
end)
it("should return a table for BackBehavior", function()
expect(type(RoactNavigation.BackBehavior)).to.equal("table")
end)
it("should return a table for Events", function()
expect(type(RoactNavigation.Events)).to.equal("table")
end)
it("should return a valid component for EventsAdapter", function()
expect(RoactNavigation.EventsAdapter.render).never.to.equal(nil)
local instance = Roact.mount(Roact.createElement(RoactNavigation.EventsAdapter, {
navigation = {
addListener = function()
return { disconnect = function() end }
end
}
}))
Roact.unmount(instance)
end)
it("should return StackPresentationStyle", function()
expect(RoactNavigation.StackPresentationStyle).to.equal(StackPresentationStyle)
end)
it("should return NoneSymbol", function()
expect(RoactNavigation.None).to.equal(NoneSymbol)
end)
it("should return a valid component for SceneView", function()
expect(RoactNavigation.SceneView.render).never.to.equal(nil)
local instance = Roact.mount(Roact.createElement(RoactNavigation.SceneView, {
navigation = {},
component = function() end,
}))
Roact.unmount(instance)
end)
it("should return a valid component for SwitchView", function()
expect(RoactNavigation.SwitchView.render).never.to.equal(nil)
local testNavigation = {
state = {
routes = {
{ routeName = "Foo", key = "Foo", }
},
index = 1,
}
}
local instance = Roact.mount(Roact.createElement(RoactNavigation.SwitchView, {
descriptors = {
Foo = {
getComponent = function()
return function() end
end,
navigation = testNavigation,
}
},
navigation = testNavigation,
}))
Roact.unmount(instance)
end)
it("should return a function for createConfigGetter", function()
expect(type(RoactNavigation.createConfigGetter)).to.equal("function")
end)
it("should return a function for getScreenForRouteName", function()
expect(type(RoactNavigation.getScreenForRouteName)).to.equal("function")
end)
it("should return a function for validateRouteConfigMap", function()
expect(type(RoactNavigation.validateRouteConfigMap)).to.equal("function")
end)
it("should return a function for getActiveChildNavigationOptions", function()
expect(type(RoactNavigation.getActiveChildNavigationOptions)).to.equal("function")
end)
end
@@ -0,0 +1,300 @@
local Roact = require(script.Parent.Parent.Roact)
local Cryo = require(script.Parent.Parent.Cryo)
local NavigationActions = require(script.Parent.NavigationActions)
local NavigationEvents = require(script.Parent.NavigationEvents)
local AppNavigationContext = require(script.Parent.views.AppNavigationContext)
local getNavigation = require(script.Parent.getNavigation)
local validate = require(script.Parent.utils.validate)
local function validateProps(props)
if not props.navigation then
return
end
local errStr =
"This navigator has both 'navigation' and container props. " ..
"It is unclear if it should own its own state. Remove the " ..
"container props or don't pass a 'navigation' prop."
for key in pairs(props) do
validate(key == "screenProps" or key == "navigation", errStr)
end
end
--[[
Construct a container Roact component that will host the navigation hierarchy
specified by your main AppComponent. AppComponent must be a navigator created by
a Roact-Navigation helper function, or a stateful Roact component
If you are using a custom stateful Roact component, make sure to set the 'router'
field so that it can be hooked into the navigation system. You must also pass your
'navigation' prop to any child navigators.
Additional props:
renderLoading - Roact component to render while the app is loading.
externalDispatchConnector - Function that Roact Navigation can use to connect to
externally triggered navigation Actions. This is useful
for external UI or handling of the Android back button.
Ex:
local connector = function(rnDispatch)
-- You store rnDispatch and call it when you want to inject
-- an event from outside RN.
return function()
-- You disconnect rnDispatch when RN calls this.
end
end
...
Roact.createElement(MyRNAppContainer, {
externalDispatchConnector = connector,
})
]]
return function(AppComponent)
validate(type(AppComponent) == "table" and AppComponent.router ~= nil,
"AppComponent must be a navigator or a stateful Roact component with a 'router' field")
local containerName = string.format("NavigationContainer(%s)", tostring(AppComponent))
local NavigationContainer = Roact.Component:extend(containerName)
function NavigationContainer.getDerivedStateFromProps(nextProps)
validateProps(nextProps)
return nil
end
function NavigationContainer:init()
validateProps(self.props)
self._actionEventSubscribers = {}
self._initialAction = NavigationActions.init()
local initialNav = nil
local containerIsStateful = self:_isStateful()
if containerIsStateful and not self.props.persistenceKey then
initialNav = AppComponent.router.getStateForAction(self._initialAction)
end
self.state = {
nav = initialNav,
}
end
function NavigationContainer:_updateExternalDispatchConnector()
local externalDispatchConnector = self.props.externalDispatchConnector
if self._subs then
self._subs()
self._subs = nil
end
if externalDispatchConnector ~= nil then
self._subs = externalDispatchConnector(function(...)
if self._isMounted then
return self:dispatch(...)
end
-- External dispatch while we're not mounted gets dropped on floor.
return false
end)
end
end
function NavigationContainer:_renderLoading()
local renderLoading = self.props.renderLoading
if renderLoading then
return renderLoading()
else
return nil
end
end
function NavigationContainer:render()
local navigation = self.props.navigation
if self:_isStateful() then
local navState = self.state.nav
if not navState then
return self:_renderLoading()
end
if not self._navigation or self._navigation.state ~= navState then
self._navigation = getNavigation(
AppComponent.router,
navState,
function(...)
return self:dispatch(...)
end,
self._actionEventSubscribers,
function(...)
return self:_getScreenProps(...)
end,
function()
return self._navigation
end
)
end
navigation = self._navigation
end
validate(navigation ~= nil, "failed to get navigation")
return Roact.createElement(AppNavigationContext.Provider, {
navigation = navigation,
}, {
-- Provide navigation prop for top-level component so it doesn't have to connect.
AppComponent = Roact.createElement(AppComponent, Cryo.Dictionary.join(self.props, {
navigation = navigation,
}))
})
end
function NavigationContainer:didMount()
self._isMounted = true
self:_updateExternalDispatchConnector()
if not self:_isStateful() then
return
end
local action = self._initialAction
local startupState = self.state.nav
if not startupState then
startupState = AppComponent.router.getStateForAction(action)
end
local function dispatchActionEvents()
-- _actionEventSubscribers is a table(handler, true), e.g. a Set container
for subscriber in pairs(self._actionEventSubscribers) do
subscriber({
type = NavigationEvents.Action,
action = action,
state = self.state.nav,
-- there is no lastState for initial mounting
})
end
end
if startupState ~= self.state.nav then
self:setState({
nav = startupState
})
end
-- This must be spawned until we get async setState callback handler in Roact
spawn(dispatchActionEvents)
end
function NavigationContainer:willUnmount()
self._isMounted = false
-- TODO: Disconnect from from URL listener once implemented
if self._subs then
self._subs()
self._subs = nil
end
end
function NavigationContainer:didUpdate(oldProps)
-- Clear cached _navState every time we update.
if self._navState == self.state.nav then
self._navState = nil
end
if self.props.externalDispatchConnector ~= oldProps.externalDispatchConnector then
self:_updateExternalDispatchConnector()
end
end
function NavigationContainer:_isStateful()
return not self.props.navigation
end
-- NOTE: Not implementing _validateProps; it is duplicate
-- NOTE: Not implementing _handleOpenURL; app should have a component
-- that transforms URLs into paths for AppContainer instead.
function NavigationContainer:_onNavigationStateChange(prevNav, nextNav, action)
local onNavigationStateChange = self.props.onNavigationStateChange
if type(onNavigationStateChange) == "function" then
onNavigationStateChange(prevNav, nextNav, action)
end
end
function NavigationContainer:_getScreenProps(propKey, defaultValue)
local screenProps = self.props.screenProps or {}
if propKey ~= nil then
return screenProps[propKey] or defaultValue
end
-- Legacy: return original table if no args provided
return screenProps
end
function NavigationContainer:dispatch(action)
if self.props.navigation then
return self.props.navigation.dispatch(action)
end
self._navState = self._navState or self.state.nav
local lastNavState = self._navState
validate(lastNavState ~= nil, "navState should be set in constructor if stateful")
local reducedState = AppComponent.router.getStateForAction(action, lastNavState)
local navState = reducedState
if not navState then
navState = lastNavState
end
local function dispatchActionEvents()
-- _actionEventSubscribers is a table(handler, true), e.g. a Set container
for subscriber in pairs(self._actionEventSubscribers) do
subscriber({
type = NavigationEvents.Action,
action = action,
state = navState,
lastState = lastNavState,
})
end
end
if reducedState == nil then
-- Router returns nil when action has been handled and there is no state change.
-- dispatch() must return true whenever something has been handled.
dispatchActionEvents()
return true
end
if navState ~= lastNavState then
-- Update cache to ensure that subsequent calls do not discard this change
self._navState = navState
-- TODO: We have to dispatch events before or after setState (which mounts/unmounts components)
-- based upon the specific event type, to ensure that pages get them in the correct order...
self:setState({
nav = navState
})
-- Must be spawned until we get async setState callback handler in Roact.
spawn(function()
self:_onNavigationStateChange(lastNavState, navState, action)
dispatchActionEvents()
-- TODO: Add call to persist navigation state here, if we ever implement it.
end)
return true
end
spawn(dispatchActionEvents)
return false
end
return NavigationContainer
end
@@ -0,0 +1,150 @@
local Cryo = require(script.Parent.Parent.Cryo)
local NavigationEvents = require(script.Parent.NavigationEvents)
local createSubscriberEventsStateTable = require(script.Parent.utils.createSubscriberEventsStateTable)
local validate = require(script.Parent.utils.validate)
--[[
This utility will fire focus and blur events for the child based upon action events
and the current navigation state.
Args:
addListener - Functor to add an event listener to navigation dispatch system.
key - The key for the subscribing child.
initialState - Initial state to set for the child event monitor (optional).
]]
return function(addListener, key, initialState)
initialState = initialState or "Blurred"
local upstreamSubscribers = {}
local subscriberMap = {
[NavigationEvents.Action] = {},
[NavigationEvents.WillFocus] = {},
[NavigationEvents.DidFocus] = {},
[NavigationEvents.WillBlur] = {},
[NavigationEvents.DidBlur] = {},
[NavigationEvents.Refocus] = {},
}
local function emit(subscriberType, payload)
local payloadWithType = Cryo.Dictionary.join(payload or {}, { type = subscriberType })
local subscribers = subscriberMap[subscriberType]
if subscribers then
for _, subs in ipairs(subscribers) do
subs(payloadWithType)
end
end
end
local function disconnectAll()
for _, subscriberList in pairs(subscriberMap) do
for x in pairs(subscriberList) do
subscriberList[x] = nil
end
end
for _, subs in pairs(upstreamSubscribers) do
if subs then
subs.disconnect()
end
end
end
-- Each event subscriber (e.g. screen) has its own state table that tracks the events which
-- drive focused/unfocused transitions. Screens all start life blurred, including the initial pages
-- displayed by a navigator. The latter need special treatment so they still receive their
-- willFocus+didFocus events.
local eventStateTable = createSubscriberEventsStateTable(key, initialState, emit, disconnectAll)
for eventType in pairs(subscriberMap) do
upstreamSubscribers[eventType] = addListener(eventType, function(payload)
-- Refocus events don't use a specialized child payload or T/A event key marks. Propagate immediately.
-- We also allow arbitrary payloads with Refocus so we do not want to try to parse the table.
if eventType == NavigationEvents.Refocus then
eventStateTable:handleEvent(tostring(NavigationEvents.Refocus), payload)
return
end
local state = payload.state
local lastState = payload.lastState
local action = payload.action
local routes = state and state.routes
local lastRoutes = lastState and lastState.routes
local focusKey = routes and routes[state.index].key or nil
local isChildFocused = focusKey == key
local isTransitioning = state and state.isTransitioning or false
local lastRoute = nil
if lastRoutes then
for _, route in ipairs(lastRoutes) do
if route.key == key then
lastRoute = route
break
end
end
end
local newRoute = nil
if routes then
for _, route in ipairs(routes) do
if route.key == key then
newRoute = route
break
end
end
end
local childPayload = {
context = string.format("%s:%s_%s", key, tostring(action.type), payload.context or 'Root'),
state = newRoute,
lastState = lastRoute,
action = action,
type = eventType,
}
-- State table figures out all the details of what conditions lead to event propagation based
-- upon a string eventKey.
local activeKey = isChildFocused and "A" or ""
local transitioningKey = isTransitioning and "T" or ""
local eventKey = tostring(eventType) .. activeKey .. transitioningKey
eventStateTable:handleEvent(eventKey, childPayload)
-- Regardless of what state transition we've propagated earlier, shut down the state machine
-- if the route has been removed from the nav history because that means the page is gone.
-- (This also disconnects the event listeners.)
if not newRoute then
eventStateTable.events.shutdown()
end
end)
end
return {
addListener = function(eventType, eventHandler)
local subscribers = subscriberMap[eventType]
validate(subscribers ~= nil, "Invalid event type '%s'", tostring(eventType))
validate(type(eventHandler) == "function",
"eventHandler for '%s' must be a function", tostring(eventType))
table.insert(subscribers, eventHandler)
return {
disconnect = function()
for idx, subs in ipairs(subscribers) do
if subs == eventHandler then
table.remove(subscribers, idx)
break
end
end
end
}
end,
emit = function(eventType, payload)
validate(eventType == NavigationEvents.Refocus,
"navigation.emit only supports NavigationEvents.Refocus currently.")
validate(payload == nil or type(payload) == "table",
"navigation.emit payloads must be a table or nil")
emit(eventType, payload)
end,
}
end
@@ -0,0 +1,115 @@
local Cryo = require(script.Parent.Parent.Cryo)
local getChildEventSubscriber = require(script.Parent.getChildEventSubscriber)
local getChildRouter = require(script.Parent.routers.getChildRouter)
local getNavigationActionCreators = require(script.Parent.routers.getNavigationActionCreators)
local getChildrenNavigationCache = require(script.Parent.getChildrenNavigationCache)
local function createParamGetter(route)
return function(paramName, defaultValue)
local params = route.params
return params and params[paramName] or defaultValue
end
end
local function getChildNavigation(navigation, childKey, getCurrentParentNavigation)
local children = getChildrenNavigationCache(navigation)
local childRoute = nil
for _, route in ipairs(navigation.state.routes) do
if route.key == childKey then
childRoute = route
break
end
end
if not childRoute then
return nil
end
local requestedChild = children[childKey]
if requestedChild and requestedChild.state == childRoute then
return requestedChild
end
local childRouter = getChildRouter(navigation.router, childRoute.routeName)
-- If the route has children that match our routes schema then get a reference
-- to the focused grandchild so we can pass the correct action creators to the
-- child router so that any action that depends on the child route will behave
-- as expected.
local focusedGrandChildRoute = nil
if childRoute.routes and type(childRoute.index) == "number" then
focusedGrandChildRoute = childRoute.routes[childRoute.index]
end
local childRouterActionCreators = childRouter and
childRouter.getActionCreators(focusedGrandChildRoute, childRoute.key) or {}
local actionCreators = Cryo.Dictionary.join(
navigation.actions or {},
navigation.router.getActionCreators(childRoute, navigation.state.key) or {},
childRouterActionCreators or {},
getNavigationActionCreators(childRoute) or {})
local actionHelpers = {}
for key, creator in pairs(actionCreators) do
actionHelpers[key] = function(...)
local action = creator(...)
return navigation.dispatch(action)
end
end
if requestedChild then
-- Update cache value for requestedChild because child's state has changed
children[childKey] = Cryo.Dictionary.join(requestedChild, actionHelpers, {
state = childRoute,
router = childRouter,
actions = actionCreators,
getParam = createParamGetter(childRoute),
})
return children[childKey]
else
-- No cached value for requestedChild. Create a new entry.
local childSubscriber = getChildEventSubscriber(navigation.addListener, childKey)
children[childKey] = Cryo.Dictionary.join(actionHelpers, {
state = childRoute,
router = childRouter,
actions = actionCreators,
getParam = createParamGetter(childRoute),
getChildNavigation = function(grandChildKey)
return getChildNavigation(children[childKey], grandChildKey, function()
local nav = getCurrentParentNavigation()
return nav and nav.getChildNavigation(childKey) or nil
end)
end,
isFocused = function()
local currentNavigation = getCurrentParentNavigation()
if not currentNavigation then
return false
end
local state = currentNavigation.state
local routes = state.routes
local index = state.index
if not currentNavigation.isFocused() then
return false
end
-- If we're transitioning to this state then we are NOT focused until the transition is over.
return (routes[index].key == childKey and state.isTransitioning ~= true) or false
end,
dispatch = navigation.dispatch,
getScreenProps = navigation.getScreenProps,
addListener = childSubscriber.addListener,
emit = childSubscriber.emit,
})
return children[childKey]
end
end
return getChildNavigation
@@ -0,0 +1,26 @@
return function(navigation)
if not navigation then
return {}
end
if not navigation._childrenNavigation then
navigation._childrenNavigation = {}
end
local childrenNavigationCache = navigation._childrenNavigation
local childKeys = {}
for _, route in ipairs(navigation.state.routes or {}) do
childKeys[route.key] = true
end
if not navigation.state.isTransitioning then
for cacheKey, _ in pairs(childrenNavigationCache) do
if not childKeys[cacheKey] then
childrenNavigationCache[cacheKey] = nil
end
end
end
return navigation._childrenNavigation
end
@@ -0,0 +1,52 @@
local Cryo = require(script.Parent.Parent.Cryo)
local NavigationEvents = require(script.Parent.NavigationEvents)
local getNavigationActionCreators = require(script.Parent.routers.getNavigationActionCreators)
local getChildNavigation = require(script.Parent.getChildNavigation)
local getChildrenNavigationCache = require(script.Parent.getChildrenNavigationCache)
return function(router, state, dispatch, actionSubscribers, getScreenProps, getCurrentNavigation)
local actions = router.getActionCreators(state, nil)
local navigation = {
actions = actions,
router = router,
state = state,
dispatch = dispatch,
getScreenProps = getScreenProps,
_childrenNavigation = getChildrenNavigationCache(getCurrentNavigation()),
}
function navigation.getChildNavigation(childKey)
return getChildNavigation(navigation, childKey, getCurrentNavigation)
end
function navigation.isFocused(childKey)
local routes = getCurrentNavigation().state.routes
local index = getCurrentNavigation().state.index
return not childKey or routes[index].key == childKey
end
function navigation.addListener(event, handler)
if event ~= NavigationEvents.Action then
return { disconnect = function() end }
else
actionSubscribers[handler] = true
return {
disconnect = function()
actionSubscribers[handler] = nil
end
}
end
end
local actionCreators = Cryo.Dictionary.join(getNavigationActionCreators(navigation.state), actions)
for actionName, _ in pairs(actionCreators) do
navigation[actionName] = function(...)
navigation.dispatch(actionCreators[actionName](...))
end
end
return navigation
end
@@ -0,0 +1,53 @@
-- Generator information:
-- Human name: Roact Navigation
-- Variable name: RoactNavigation
-- Repo name: roact-navigation
return {
-- Navigation container construction
createAppContainer = require(script.createAppContainer),
getNavigation = require(script.getNavigation),
-- Context Access
Context = require(script.views.AppNavigationContext),
Provider = require(script.views.AppNavigationContext).Provider,
Consumer = require(script.views.AppNavigationContext).Consumer,
connect = require(script.views.AppNavigationContext).connect,
withNavigation = require(script.views.withNavigation),
withNavigationFocus = require(script.views.withNavigationFocus),
-- Navigators
createStackNavigator = require(script.navigators.createStackNavigator),
createSwitchNavigator = require(script.navigators.createSwitchNavigator),
createNavigator = require(script.navigators.createNavigator),
-- Routers
StackRouter = require(script.routers.StackRouter),
SwitchRouter = require(script.routers.SwitchRouter),
TabRouter = require(script.routers.TabRouter),
-- Navigation Actions
Actions = require(script.NavigationActions),
StackActions = require(script.StackActions),
BackBehavior = require(script.BackBehavior),
-- Navigation Events
Events = require(script.NavigationEvents),
EventsAdapter = require(script.views.NavigationEventsAdapter),
-- Additional Types
StackPresentationStyle = require(script.views.StackView.StackPresentationStyle),
None = require(script.NoneSymbol),
-- Screen Views
SceneView = require(script.views.SceneView),
SwitchView = require(script.views.SwitchView),
StackView = require(script.views.StackView.StackView),
-- Utilities
createConfigGetter = require(script.routers.createConfigGetter),
getScreenForRouteName = require(script.routers.getScreenForRouteName),
validateRouteConfigMap = require(script.routers.validateRouteConfigMap),
getActiveChildNavigationOptions = require(script.utils.getActiveChildNavigationOptions),
}
@@ -0,0 +1,78 @@
return function()
local Roact = require(script.Parent.Parent.Parent.Parent.Roact)
local createNavigator = require(script.Parent.Parent.createNavigator)
local testRouter = {
getScreenOptions = function() return nil end,
}
it("should return a Roact component that exposes navigator fields", function()
local testComponentMounted = nil
local TestViewComponent = Roact.Component:extend("TestViewComponent")
function TestViewComponent:render() end
function TestViewComponent:didMount() testComponentMounted = true end
function TestViewComponent:willUnmount() testComponentMounted = false end
local testNavOptions = {}
local navigator = createNavigator(TestViewComponent, testRouter, {
navigationOptions = testNavOptions,
})
expect(type(navigator.render)).to.equal("function")
expect(navigator.router).to.equal(testRouter)
expect(navigator.navigationOptions).to.equal(testNavOptions)
local testNavigation = {
state = {
routes = {
{ routeName = "Foo", key = "Foo" },
},
index = 1
},
getChildNavigation = function() return nil end, -- stub
}
-- Try to mount it
local instance = Roact.mount(Roact.createElement(navigator, {
navigation = testNavigation
}))
expect(testComponentMounted).to.equal(true)
Roact.unmount(instance)
expect(testComponentMounted).to.equal(false)
end)
it("should throw when trying to mount without navigation prop", function()
local TestViewComponent = function() end
local navigator = createNavigator(TestViewComponent, testRouter, {
navigationOptions = {}
})
expect(function()
Roact.mount(Roact.createElement(navigator))
end).to.throw()
end)
it("should throw when trying to mount without routes", function()
local TestViewComponent = function() end
local navigator = createNavigator(TestViewComponent, testRouter, {
navigationOptions = {}
})
local testNavigation = {
state = {
index = 1
},
getChildNavigation = function() return nil end, -- stub
}
expect(function()
Roact.mount(Roact.createElement(navigator, {
navigation = testNavigation
}))
end).to.throw()
end)
end
@@ -0,0 +1,43 @@
return function()
local Roact = require(script.Parent.Parent.Parent.Parent.Roact)
local createStackNavigator = require(script.Parent.Parent.createStackNavigator)
local getChildNavigation = require(script.Parent.Parent.Parent.getChildNavigation)
it("should return a mountable Roact component", function()
local navigator = createStackNavigator({
routes = {
Foo = function() end
},
initialRouteName = "Foo",
})
local testNavigation = {
state = {
routes = {
{ routeName = "Foo", key = "Foo" },
},
index = 1
},
router = navigator.router
}
function testNavigation.getChildNavigation(childKey)
return getChildNavigation(testNavigation, childKey, function()
return testNavigation
end)
end
function testNavigation.addListener(symbol, callback)
return {
disconnect = function() end
}
end
local instance = Roact.mount(Roact.createElement(navigator, {
navigation = testNavigation
}))
Roact.unmount(instance)
end)
end
@@ -0,0 +1,43 @@
return function()
local Roact = require(script.Parent.Parent.Parent.Parent.Roact)
local createSwitchNavigator = require(script.Parent.Parent.createSwitchNavigator)
local getChildNavigation = require(script.Parent.Parent.Parent.getChildNavigation)
it("should return a mountable Roact component", function()
local navigator = createSwitchNavigator({
routes = {
Foo = function() end
},
initialRouteName = "Foo",
})
local testNavigation = {
state = {
routes = {
{ routeName = "Foo", key = "Foo" },
},
index = 1
},
router = navigator.router
}
function testNavigation.getChildNavigation(childKey)
return getChildNavigation(testNavigation, childKey, function()
return testNavigation
end)
end
function testNavigation.addListener(symbol, callback)
return {
disconnect = function() end
}
end
local instance = Roact.mount(Roact.createElement(navigator, {
navigation = testNavigation
}))
Roact.unmount(instance)
end)
end
@@ -0,0 +1,78 @@
local Roact = require(script.Parent.Parent.Parent.Roact)
local Cryo = require(script.Parent.Parent.Parent.Cryo)
local validate = require(script.Parent.Parent.utils.validate)
return function(navigatorViewComponent, router, navigationConfig)
local Navigator = Roact.Component:extend("Navigator")
-- These statics need to be accessible to routers
Navigator.router = router
Navigator.navigationOptions = navigationConfig.navigationOptions
function Navigator:init()
local screenProps = self.props.screenProps
self.state = {
descriptors = {},
screenProps = screenProps
}
end
function Navigator.getDerivedStateFromProps(nextProps, prevState)
local prevDescriptors = prevState.descriptors
local navigation = nextProps.navigation
local screenProps = nextProps.screenProps
validate(navigation ~= nil, "The navigation prop is missing for this navigator")
local routes = navigation.state.routes
validate(type(routes) == "table", "No 'routes' found in navigation state. " ..
"Don't try to pass the navigation prop from a Roact component to a Navigator child.")
local descriptors = {}
for _, route in ipairs(routes) do
if prevDescriptors and prevDescriptors[route.key] and
route == prevDescriptors[route.key].state and
screenProps == prevState.screenProps then
descriptors[route.key] = prevDescriptors[route.key]
else
local getComponent = function()
return router.getComponentForRouteName(route.routeName)
end
local childNavigation = navigation.getChildNavigation(route.key)
local options = router.getScreenOptions(childNavigation, screenProps)
descriptors[route.key] = {
key = route.key,
getComponent = getComponent,
options = options,
state = route,
navigation = childNavigation,
}
end
end
return {
descriptors = descriptors,
screenProps = screenProps,
}
end
function Navigator:render()
local navigation = self.props.navigation
local screenProps = self.state.screenProps
local descriptors = self.state.descriptors
return Roact.createElement(navigatorViewComponent, Cryo.Dictionary.join(self.props, {
screenProps = screenProps,
navigation = navigation,
navigationConfig = navigationConfig,
descriptors = descriptors,
}))
end
return Navigator
end
@@ -0,0 +1,12 @@
local Cryo = require(script.Parent.Parent.Parent.Cryo)
local createNavigator = require(script.Parent.createNavigator)
local StackRouter = require(script.Parent.Parent.routers.StackRouter)
local StackView = require(script.Parent.Parent.views.StackView.StackView)
return function(config)
local router = StackRouter(config)
return createNavigator(StackView, router, Cryo.Dictionary.join(config, {
routes = Cryo.None, -- navigator config doesn't need routes
}))
end
@@ -0,0 +1,27 @@
local Cryo = require(script.Parent.Parent.Parent.Cryo)
local createNavigator = require(script.Parent.createNavigator)
local SwitchRouter = require(script.Parent.Parent.routers.SwitchRouter)
local SwitchView = require(script.Parent.Parent.views.SwitchView)
--[[
Creates a navigator component that provides simple screen "switcher" behavior.
Each page is mutually exclusive and no transition animation is used.
Additional config options:
order : List<string>
Specifies the index order for page components, e.g. for use with tab bars.
keepVisitedScreensMounted : Boolean (false)
Set to true if you want to keep previously visited screens mounted for better performance.
resetOnBlur : Boolean (true)
Set to false if you want to preserve existing state for child navigators.
backBehavior : BackBehavior (None)
Set to BackBehavior.InitialRoute to allow a goBack() operation to return to the
initial route name. By default, the SwitchNavigator will not do anything on a back action.
]]
return function(config)
local router = SwitchRouter(config)
return createNavigator(SwitchView, router, Cryo.Dictionary.join(config, {
routes = Cryo.None, -- navigator config doesn't need routes, remove from props
}))
end
@@ -0,0 +1,565 @@
local Cryo = require(script.Parent.Parent.Parent.Cryo)
local NavigationActions = require(script.Parent.Parent.NavigationActions)
local StackActions = require(script.Parent.Parent.StackActions)
local KeyGenerator = require(script.Parent.Parent.utils.KeyGenerator)
local StateUtils = require(script.Parent.Parent.StateUtils)
local getScreenForRouteName = require(script.Parent.getScreenForRouteName)
local createConfigGetter = require(script.Parent.createConfigGetter)
local validateRouteConfigMap = require(script.Parent.validateRouteConfigMap)
local validate = require(script.Parent.Parent.utils.validate)
local NavigationSymbol = require(script.Parent.Parent.NavigationSymbol)
local NoneSymbol = require(script.Parent.Parent.NoneSymbol)
local STACK_ROUTER_ROOT_KEY = "StackRouterRoot"
local CHILD_IS_SCREEN = NavigationSymbol("CHILD_IS_SCREEN")
local defaultActionCreators = function() return {} end
local function behavesLikePushAction(action)
return action.type == NavigationActions.Navigate or
action.type == StackActions.Push
end
local function isResetToRootStack(action)
return action.type == StackActions.Reset and action.key == NoneSymbol
end
return function(config)
validate(type(config) == "table", "config must be a table")
local routeConfigs = validateRouteConfigMap(config.routes)
local routeNames = Cryo.Dictionary.keys(routeConfigs)
-- find child child routers
local childRouters = {}
for _, routeName in ipairs(routeNames) do
local screen = getScreenForRouteName(routeConfigs, routeName)
if type(screen) == "table" and screen.router then
-- if it has a router then it's a navigator
childRouters[routeName] = screen.router
else
-- TODO: This is a hack to make this code behave like React-Navigation's usage of
-- null and undefined values for childRouters. We should come up with a better approach.
childRouters[routeName] = CHILD_IS_SCREEN
end
end
local getCustomActionCreators = config.getCustomActionCreators or defaultActionCreators
local initialRouteParams = config.initialRouteParams or {}
local initialRouteName = validate(config.initialRouteName, "initialRouteName must be provided")
local initialRouteIndex = Cryo.List.find(routeNames, initialRouteName)
-- dump an error if initialRouteName is not in routes.
if initialRouteIndex == nil then
local availableRouteStr = ""
for _, name in ipairs(routeNames) do
availableRouteStr = availableRouteStr .. name .. ","
end
error(string.format("Invalid initialRouteName '%s'. Must be one of [%s]", initialRouteName, availableRouteStr), 2)
end
local initialChildRouter = childRouters[initialRouteName]
local function getInitialState()
local route = {}
if initialChildRouter ~= nil and initialChildRouter ~= CHILD_IS_SCREEN then
route = initialChildRouter.getStateForAction(NavigationActions.init({
params = initialRouteParams,
}))
end
local initialRouteConfig = routeConfigs[initialRouteName]
local initialRouteConfigParams = type(initialRouteConfig) == "table" and initialRouteConfig.params or {}
local params = Cryo.Dictionary.join(
initialRouteConfigParams, -- params set in routes table!
route.params or {},
initialRouteParams or {} -- params provided at top level
)
local initialRouteKey = config.initialRouteKey
route = Cryo.Dictionary.join(route, params, {
routeName = initialRouteName,
key = initialRouteKey or KeyGenerator.generateKey()
})
return {
key = STACK_ROUTER_ROOT_KEY,
isTransitioning = false,
index = 1,
routes = { route }
}
end
local function getParamsForRouteAndAction(routeName, action)
local routeConfig = routeConfigs[routeName]
if type(routeConfig) == "table" and routeConfig.params then
return Cryo.Dictionary.join(routeConfig.params, action.params)
else
return action.params
end
end
-- Strip out the CHILD_IS_SCREEN hacked elements before exposing publicly.
local strippedChildRouters = {}
for routerName, router in pairs(childRouters) do
if router ~= CHILD_IS_SCREEN then
strippedChildRouters[routerName] = router
end
end
local StackRouter = {
childRouters = strippedChildRouters,
getScreenOptions = createConfigGetter(routeConfigs, config.defaultNavigationOptions),
_CHILD_IS_SCREEN = CHILD_IS_SCREEN, -- expose symbol for testing purposes
}
function StackRouter.getComponentForState(state)
local activeChildRoute = state.routes[state.index] or {}
local routeName = activeChildRoute.routeName
validate(routeName, "There is no route defined for index '%d'. " ..
"Make sure that you passed in a navigation state with a " ..
"valid stack index.", state.index)
local childRouter = childRouters[routeName]
if childRouter ~= nil and childRouter ~= CHILD_IS_SCREEN then
return childRouters[routeName].getComponentForState(activeChildRoute)
end
return getScreenForRouteName(routeConfigs, routeName)
end
function StackRouter.getComponentForRouteName(routeName)
return getScreenForRouteName(routeConfigs, routeName)
end
function StackRouter.getActionCreators(route, navStateKey)
return Cryo.Dictionary.join(getCustomActionCreators(route, navStateKey), {
pop = function(n, params)
return StackActions.pop(Cryo.Dictionary.join({
n = n,
}, params or {}))
end,
popToTop = function(params)
return StackActions.popToTop(params)
end,
push = function(routeName, params, action)
return StackActions.push({
routeName = routeName,
params = params,
action = action,
})
end,
replace = function(replaceWith, params, action, newKey)
if type(replaceWith) == "string" then
return StackActions.replace({
routeName = replaceWith,
params = params,
action = action,
key = route.key,
newKey = newKey,
})
end
validate(type(replaceWith) == "table", "replaceWith must be a table or string")
validate(params == nil, "params cannot be provided to .replace() when specifying a table")
validate(action == nil, "Child action cannot be provided to .replace() when specifying a table")
validate(newKey == nil, "newKey cannot be provided to .replace() when specifying a table")
return StackActions.replace(replaceWith)
end,
reset = function(actions, index)
local resetIndex = index
if index == nil then
resetIndex = #actions
end
return StackActions.reset({
actions = actions,
index = resetIndex,
key = navStateKey,
})
end,
dismiss = function()
return NavigationActions.back({
key = navStateKey,
})
end,
})
end
function StackRouter.getStateForAction(action, state)
-- Set up initial state if needed
state = state or getInitialState()
local activeChildRoute = state.routes[state.index]
if not isResetToRootStack(action) and action.type ~= NavigationActions.Navigate then
local activeChildRouter = childRouters[activeChildRoute.routeName]
if activeChildRouter ~= nil and activeChildRouter ~= CHILD_IS_SCREEN then
local route = activeChildRouter.getStateForAction(action, activeChildRoute)
if route ~= nil and route ~= activeChildRoute then
return StateUtils.replaceAt(
state,
activeChildRoute.key,
route,
action.type == NavigationActions.SetParams -- don't change index for setParam action
)
end
end
elseif action.type == NavigationActions.Navigate then
-- Traverse routes from top of the stack to the bottom; active route has first opportunity
for i = #state.routes, 1, -1 do
local childRoute = state.routes[i]
local childRouter = childRouters[childRoute.routeName]
local childAction = action
if action.routeName == childRoute.routeName and action.action then
childAction = action.action
end
if childRouter ~= nil and childRouter ~= CHILD_IS_SCREEN then
local nextRouteState = childRouter.getStateForAction(childAction, childRoute)
if nextRouteState == nil or nextRouteState ~= childRoute then
local newState = StateUtils.replaceAndPrune(
state,
nextRouteState and nextRouteState.key or childRoute.key,
nextRouteState and nextRouteState or childRoute
)
local newTransitioning = state.isTransitioning
if state.index ~= newState.index then
newTransitioning = action.immediate ~= true
end
return Cryo.Dictionary.join(newState, {
isTransitioning = newTransitioning,
})
end
end
end
end
-- Handle push and navigation actions. This must happen after focused child router
-- has had its chance to handle the action.
if behavesLikePushAction(action) and childRouters[action.routeName] ~= nil then
local childRouter = childRouters[action.routeName]
validate(action.type ~= StackActions.Push or action.key == nil,
"StackRouter does not support key on the push action")
-- Before pushing new route, try to find existing one in the stack.
local lastRouteIndex = nil
for idx, route in ipairs(state.routes) do
if (action.key and route.key == action.key) or (route.routeName == action.routeName) then
lastRouteIndex = idx
break
end
end
-- An instance of this route exists already and we're dealing with a Navigate action.
if action.type ~= StackActions.Push and lastRouteIndex ~= nil then
-- If index or params have not changed, leave state alone
if state.index == lastRouteIndex and not action.params then
return nil
end
-- Remove unused routes at tail
local tailIndex = state.index == lastRouteIndex and lastRouteIndex or lastRouteIndex + 1
local routes = Cryo.List.removeRange(state.routes, tailIndex, #state.routes)
-- Apply params if provided
if action.params then
local route = state.routes[lastRouteIndex]
routes[lastRouteIndex] = Cryo.Dictionary.join(route, {
params = Cryo.Dictionary.join(route.params or {}, action.params)
})
end
-- Return state with new index, changing isTransitioning only if index has changed
local newIsTransitioning = state.isTransitioning
if state.index ~= lastRouteIndex then
newIsTransitioning = action.immediate ~= true
end
return Cryo.Dictionary.join(state, {
isTransitioning = newIsTransitioning,
index = lastRouteIndex,
routes = routes,
})
end
local route
if childRouter ~= CHILD_IS_SCREEN then
-- Delegate to child router
local childAction = action.action or NavigationActions.init({
params = getParamsForRouteAndAction(action.routeName, action)
})
route = Cryo.Dictionary.join({
-- TODO: Does it make sense to wipe out the params here, or to incorporate params at all?
params = getParamsForRouteAndAction(action.routeName, action),
}, childRouter.getStateForAction(childAction), {
routeName = action.routeName,
key = action.key or KeyGenerator.generateKey(),
})
else
-- Create new route from scratch
route = {
params = getParamsForRouteAndAction(action.routeName, action),
routeName = action.routeName,
key = action.key or KeyGenerator.generateKey(),
}
end
return Cryo.Dictionary.join(StateUtils.push(state, route), {
isTransitioning = action.immediate ~= true,
})
elseif action.type == StackActions.Push and childRouters[action.routeName] == nil then
-- Return original state to bubble the action up
return state
end
-- Handle navigation to other child routers that are not pushed yet.
if behavesLikePushAction(action) then
local childRouterNames = Cryo.Dictionary.keys(childRouters)
for _, childRouterName in ipairs(childRouterNames) do
local childRouter = childRouters[childRouterName]
if childRouter ~= nil and childRouter ~= CHILD_IS_SCREEN then
-- Start with blank state for each child router
local initChildRoute = childRouter.getStateForAction(NavigationActions.init())
-- Check to see if it handles our action
local navigatedChildRoute = childRouter.getStateForAction(action, initChildRoute)
local routeToPush = nil
if navigatedChildRoute == nil then
-- Push initial route if the router returned nil when handling action
routeToPush = initChildRoute
elseif navigatedChildRoute ~= initChildRoute then
-- Push new route if state changed in response to this action
routeToPush = navigatedChildRoute
end
if routeToPush then
local route = Cryo.Dictionary.join(routeToPush, {
routeName = childRouterName,
key = action.key or KeyGenerator.generateKey(),
})
return Cryo.Dictionary.join(StateUtils.push(state, route), {
isTransitioning = action.immediate ~= true,
})
end
end
end
end
-- Handle pop-to-top behavior. This must happen after children have had a chance to handle
-- the action, so that the inner stack always pops first.
if action.type == StackActions.PopToTop then
-- Refuse to handle pop to top if a key is given that does not correspond to this router
if action.key and state.key ~= action.key then
return state
end
-- If we're already at the top then return current state to allow action to bubble up.
if state.index <= 1 then
return state
end
return Cryo.Dictionary.join(state, {
isTransitioning = action.immediate ~= true,
index = 1,
routes = { state.routes[1] }
})
end
if action.type == StackActions.Replace then
local routeIndex = nil
-- If there is no key, set index to last route in stack
if not action.key and #state.routes > 0 then
routeIndex = #state.routes
else
for idx, route in ipairs(state.routes) do
if route.key == action.key then
routeIndex = idx
break
end
end
end
if routeIndex then
local childRouter = childRouters[action.routeName]
local childState = {}
if childRouter ~= nil and childRouter ~= CHILD_IS_SCREEN then
local childAction = action.action or NavigationActions.init({
params = getParamsForRouteAndAction(action.routeName, action)
})
childState = childRouter.getStateForAction(childAction)
end
-- shallow copy and update routes
local routes = Cryo.List.join(state.routes)
routes[routeIndex] = Cryo.Dictionary.join({
params = getParamsForRouteAndAction(action.routeName, action),
}, childState, {
routeName = action.routeName,
key = action.newKey or KeyGenerator.generateKey(),
})
return Cryo.Dictionary.join(state, {
routes = routes,
})
end
end
if action.type == NavigationActions.CompleteTransition and
(action.key == nil or action.key == state.key) and
action.toChildKey == state.routes[state.index].key and
state.isTransitioning then
return Cryo.Dictionary.join(state, {
isTransitioning = false,
})
end
if action.type == NavigationActions.SetParams then
local key = action.key
local lastRouteIndex = nil
local lastRoute = nil
for idx, route in ipairs(state.routes) do
if route.key == key then
lastRouteIndex = idx
lastRoute = route
break
end
end
if lastRoute then
local params = Cryo.Dictionary.join(lastRoute.params or {}, action.params or {})
-- shallow copy and update routes
local routes = Cryo.List.join(state.routes)
routes[lastRouteIndex] = Cryo.Dictionary.join(lastRoute, {
params = params,
})
return Cryo.Dictionary.join(state, {
routes = routes,
})
end
end
if action.type == StackActions.Reset then
-- Only handle reset actions with matching key (or none)
if action.key ~= nil and action.key ~= state.key then
return state
end
local specifiedActions = action.actions or {}
local newRoutes = {}
for _, newStackAction in ipairs(specifiedActions) do
local router = childRouters[newStackAction.routeName]
local childState = {}
if router ~= nil and router ~= CHILD_IS_SCREEN then
local childAction = newStackAction.action or NavigationActions.init({
params = getParamsForRouteAndAction(newStackAction.routeName, newStackAction),
})
childState = router.getStateForAction(childAction)
end
table.insert(newRoutes, Cryo.Dictionary.join({
params = getParamsForRouteAndAction(newStackAction.routeName, newStackAction)
}, childState, {
routeName = newStackAction.routeName,
key = newStackAction.key or KeyGenerator.generateKey(),
}))
end
return Cryo.Dictionary.join(state, {
routes = newRoutes,
index = action.index or #specifiedActions,
})
end
if action.type == NavigationActions.Back or
action.type == StackActions.Pop then
local key = action.key
local n = action.n
local immediate = action.immediate
local backRouteIndex = state.index -- index to go back *FROM*
if action.type == StackActions.Pop and n ~= nil then
backRouteIndex = math.max(1, state.index - n + 1)
elseif key and key ~= NoneSymbol then
-- If key is specified and is not ours, we should NOT try to navigate back
-- because it might be intended for our parent! (So clear the backRouteIndex.)
backRouteIndex = 0
for idx, route in ipairs(state.routes) do
if route.key == key then
backRouteIndex = idx
break
end
end
end
if backRouteIndex > 1 then
return Cryo.Dictionary.join(state, {
routes = Cryo.List.removeRange(state.routes, backRouteIndex, #state.routes),
index = backRouteIndex - 1,
isTransitioning = immediate ~= true,
})
end
end
-- At this point, we've handled the behavior of the active route and any
-- stack actions. Now we allow non-active child routers to try to process the action,
-- and switch to them if they can handle it.
local keyIndex = action.key and StateUtils.indexOf(state, action.key) or nil
-- Traverse from top of stack to bottom.
for i = #state.routes, 1, -1 do
local childRoute = state.routes[i]
-- Skip over the active route since we already let it try.
-- Also, skip calling getStateForAction on other child routers
-- if the provided key is in the route's state
if (childRoute.key ~= activeChildRoute.key) and
(not keyIndex or childRoute.key == action.key) then
local childRouter = childRouters[childRoute.routeName]
if childRouter ~= nil and childRouter ~= CHILD_IS_SCREEN then
local route = childRouter.getStateForAction(action, childRoute)
if not route then
return state
end
if route ~= childRoute then
return StateUtils.replaceAt(
state,
childRoute.key,
route,
-- don't change index for these action types
action.type == NavigationActions.SetParams or
action.type == NavigationActions.CompleteTransition
)
end
end
end
end
return state
end
-- TODO: Implement StackRouter.getPathAndParamsForState after we add path expression support
-- TODO: Implement StackRouter.getActionForPathAndParams after we add path expression support
return StackRouter
end
@@ -0,0 +1,331 @@
local Cryo = require(script.Parent.Parent.Parent.Cryo)
local NavigationActions = require(script.Parent.Parent.NavigationActions)
local BackBehavior = require(script.Parent.Parent.BackBehavior)
local getScreenForRouteName = require(script.Parent.getScreenForRouteName)
local createConfigGetter = require(script.Parent.createConfigGetter)
local validateRouteConfigMap = require(script.Parent.validateRouteConfigMap)
local validate = require(script.Parent.Parent.utils.validate)
local defaultActionCreators = function() return {} end
-- Until Cryo has a List function to do this, provide shallow copy+replace index
local function immutableReplaceListIndex(list, index, value)
local result = {}
for i, ival in ipairs(list) do
result[i] = ival
end
result[index] = value
return result
end
local function childrenUpdateWithoutSwitchingIndex(actionType)
return actionType == NavigationActions.SetParams or
actionType == NavigationActions.CompleteTransition
end
local function collectChildRouters(routeConfigs)
local childRouters = {}
for routeName, _ in pairs(routeConfigs) do
local screen = getScreenForRouteName(routeConfigs, routeName)
if type(screen) == "table" and screen.router then
childRouters[routeName] = screen.router
end
end
return childRouters
end
local function getParamsForRoute(routeConfigs, routeName, initialParams)
local routeConfig = routeConfigs[routeName]
if type(routeConfig) == "table" and routeConfig.params then
return Cryo.Dictionary.join(routeConfig.params, initialParams)
else
return initialParams
end
end
return function(config)
validate(type(config) == "table", "config must be a table")
local routeConfigs = validateRouteConfigMap(config.routes)
-- Order is how we map the active index into the list of possible routes.
-- Lua does not guarantee any sense of order of table keys in dictionaries, so
-- we have to require the initialRouteName parameter instead defaulting to the
-- first route in the map.
local order = config.order or Cryo.Dictionary.keys(routeConfigs)
local getCustomActionCreators = config.getCustomActionCreators or defaultActionCreators
local initialRouteParams = config.initialRouteParams or {}
local initialRouteName = validate(config.initialRouteName,
"initialRouteName must be provided")
local backBehavior = config.backBehavior or BackBehavior.None
local backShouldNavigateToInitialRoute = backBehavior == BackBehavior.InitialRoute
local resetOnBlur = true
if type(config.resetOnBlur) == "boolean" then
resetOnBlur = config.resetOnBlur
end
local initialRouteIndex = Cryo.List.find(order, initialRouteName)
if initialRouteIndex == nil then
local availableRouteStr = ""
for _, name in ipairs(order) do
availableRouteStr = availableRouteStr .. name .. ","
end
error(string.format("Invalid initialRouteName '%s'. Must be one of [%s]", initialRouteName, availableRouteStr), 2)
end
local childRouters = collectChildRouters(routeConfigs)
local function resetChildRoute(routeName)
-- TODO: Do we want to merge initialRouteParams on TOP of route-specific params?
-- There is a comment in RoactNavigation that this is incorrect behavior, but they
-- do it to be consistent with their StackRouter. Do we even need this feature?
local initialParams = routeName == initialRouteName and initialRouteParams or {}
local params = getParamsForRoute(routeConfigs, routeName, initialParams)
local childRouter = childRouters[routeName]
if childRouter then
local childAction = NavigationActions.init()
return Cryo.Dictionary.join(childRouter.getStateForAction(childAction), {
key = routeName,
routeName = routeName,
params = params,
})
else
return {
key = routeName,
routeName = routeName,
params = params,
}
end
end
local function getNextState(prevState, possibleNextState)
if not prevState then
return possibleNextState
end
if prevState.index ~= possibleNextState.index and resetOnBlur then
local prevRouteName = prevState.routes[prevState.index].routeName
local nextRoutes = immutableReplaceListIndex(
possibleNextState.routes,
prevState.index,
resetChildRoute(prevRouteName))
return Cryo.Dictionary.join(possibleNextState, {
routes = nextRoutes,
})
else
return possibleNextState
end
end
local function getInitialState()
return {
routes = Cryo.List.map(order, resetChildRoute),
index = initialRouteIndex,
isTransitioning = false,
}
end
local SwitchRouter = {
childRouters = childRouters,
getScreenOptions = createConfigGetter(routeConfigs, config.defaultNavigationOptions)
}
function SwitchRouter.getActionCreators(route, stateKey)
return getCustomActionCreators(route, stateKey)
end
function SwitchRouter.getStateForAction(action, inputState)
local prevState = inputState and Cryo.Dictionary.join(inputState) or nil
local state = inputState or getInitialState()
local activeChildIndex = state.index
if action.type == NavigationActions.Init then
-- TODO: React-Navigation has a comment that wonders why we merge params into child routes.
-- Need to understand if we really want to do this.
local params = action.params
if params then
state.routes = Cryo.List.map(state.routes, function(route)
local initialParams = route.routeName == initialRouteName and initialRouteParams or {}
return Cryo.Dictionary.join(route, {
params = Cryo.Dictionary.join(route.params, params, initialParams)
})
end)
end
end
-- Let the active child try to handle the action first.
local activeChildLastState = state.routes[state.index]
local activeChildRouter = childRouters[order[state.index]]
if activeChildRouter then
local activeChildState = activeChildRouter.getStateForAction(action, activeChildLastState)
if not activeChildState and inputState then
-- Child ran into error with known inputState. Propagate to caller.
return nil
end
if activeChildState and activeChildState ~= activeChildLastState then
local routes = immutableReplaceListIndex(state.routes, state.index, activeChildState)
return getNextState(prevState, Cryo.Dictionary.join(state, {
routes = routes
}))
end
end
-- Child did not handle it, so try to process the action ourselves.
local isBackEligible = not action.key or action.key == activeChildLastState.key
if action.type == NavigationActions.Back then
if isBackEligible and backShouldNavigateToInitialRoute then
activeChildIndex = initialRouteIndex
else
return state
end
end
local didNavigate = false
if action.type == NavigationActions.Navigate then
for index, childId in ipairs(order) do
if childId == action.routeName then
activeChildIndex = index
didNavigate = true
break
end
end
if didNavigate then
local childState = state.routes[activeChildIndex]
local childRouter = childRouters[action.routeName]
local newChildState = childState
if action.action and childRouter then
local childStateUpdate = childRouter.getStateForAction(action.action, childState)
if childStateUpdate then
newChildState = childStateUpdate
end
end
if action.params then
newChildState = Cryo.Dictionary.join(newChildState, {
params = Cryo.Dictionary.join(newChildState.params or {}, action.params)
})
end
if newChildState ~= childState then
local routes = immutableReplaceListIndex(state.routes, activeChildIndex, newChildState)
local nextState = Cryo.Dictionary.join(state, {
routes = routes,
index = activeChildIndex,
})
return getNextState(prevState, nextState)
elseif newChildState == childState and state.index == activeChildIndex and prevState then
return nil
end
end
end
if action.type == NavigationActions.SetParams then
local key = action.key
local lastIndex, lastRoute
for index, route in ipairs(state.routes) do
if route.key == key then
lastIndex = index
lastRoute = route
break
end
end
if lastRoute then
local params = Cryo.Dictionary.join(lastRoute.params or {}, action.params)
local mergedRoute = Cryo.Dictionary.join(lastRoute, {
params = params
})
local routes = immutableReplaceListIndex(state.routes, lastIndex, mergedRoute)
return getNextState(prevState, Cryo.Dictionary.join(state, {
routes = routes
}))
end
end
if activeChildIndex ~= state.index then
return getNextState(prevState, Cryo.Dictionary.join(state, { index = activeChildIndex }))
elseif didNavigate and not inputState then
return state
elseif didNavigate then
return Cryo.Dictionary.join(state)
end
-- Let other children handle it and switch to first child that returns a new state
local index = state.index
local routes = state.routes
for i, childId in ipairs(order) do
if i ~= index then
local childRouter = childRouters[childId]
local childState = routes[i]
if childRouter then
childState = childRouter.getStateForAction(action, childState)
end
if not childState then
index = i
break
end
if childState ~= routes[i] then
routes = immutableReplaceListIndex(routes, i, childState)
index = i
break
end
end
end
-- Nested routers can be updated after switching children with actions such as SetParams
-- and CompleteTransition
if childrenUpdateWithoutSwitchingIndex(action.type) then
index = state.index
end
if index ~= state.index or routes ~= state.routes then
return getNextState(prevState, Cryo.Dictionary.join(state, {
index = index,
routes = routes,
}))
end
return state
end
function SwitchRouter.getComponentForState(state)
local activeRoute = state.routes[state.index] or {}
local routeName = activeRoute.routeName
validate(routeName, "There is no route defined for index '%d'. " ..
"Make sure that you passed in a navigation state with a " ..
"valid tab/screen index.", state.index)
local childRouter = childRouters[routeName]
if childRouter then
return childRouter.getComponentForState(state.routes[state.index])
else
return getScreenForRouteName(routeConfigs, routeName)
end
end
function SwitchRouter.getComponentForRouteName(routeName)
return getScreenForRouteName(routeConfigs, routeName)
end
-- TODO: Implement SwitchRouter.getPathAndParamsForState after we add path expression support
-- TODO: Implement SwitchRouter.getActionForPathAndParams after we add path expression support
return SwitchRouter
end
@@ -0,0 +1,13 @@
local Cryo = require(script.Parent.Parent.Parent.Cryo)
local SwitchRouter = require(script.Parent.SwitchRouter)
local BackBehavior = require(script.Parent.Parent.BackBehavior)
return function(config)
-- Provide defaults suitable for tab routing.
local modifiedConfig = Cryo.Dictionary.join({
resetOnBlur = false,
backBehavior = BackBehavior.InitialRoute,
}, config)
return SwitchRouter(modifiedConfig)
end
@@ -0,0 +1,691 @@
return function()
local SwitchRouter = require(script.Parent.Parent.SwitchRouter)
local NavigationActions = require(script.Parent.Parent.Parent.NavigationActions)
local BackBehavior = require(script.Parent.Parent.Parent.BackBehavior)
local function expectError(functor, msg)
local status, err = pcall(functor)
if status ~= false then
error("expectError: Test function should have thrown error, but it passed", 2)
end
if string.find(err, msg) == nil then
error(string.format("expectError: Expected error message '%s' not found in actual message: '%s'", msg, err), 2)
end
end
it("should be a function", function()
expect(type(SwitchRouter)).to.equal("function")
end)
it("should throw when passed a non-table", function()
expectError(function()
SwitchRouter(5)
end, "config must be a table")
end)
it("should throw for invalid routes config", function()
expect(function()
SwitchRouter({ routes = 5 })
end).to.throw() -- throw is from validateRouteConfigs, so do not depend on message
end)
it("should throw if initialRouteName is not provided", function()
expectError(function()
SwitchRouter({ routes = {
Foo = function() end,
} })
end, "initialRouteName must be provided")
end)
it("should throw if initialRouteName is not found in routes table", function()
expectError(function()
SwitchRouter({
routes = {
Foo = function() end,
Bar = function() end,
},
initialRouteName = "MyRoute",
})
end, "Invalid initialRouteName 'MyRoute'. Must be one of %[Bar,Foo,%]")
end)
it("should expose childRouters as a member", function()
local router = SwitchRouter({
routes = {
Foo = {
screen = {
render = function() end,
router = "A",
},
},
Bar = {
screen = {
render = function() end,
router = "B",
},
},
},
initialRouteName = "Foo",
})
expect(router.childRouters.Foo).to.equal("A")
expect(router.childRouters.Bar).to.equal("B")
end)
describe("getScreenOptions tests", function()
it("should correctly configure default screen options", function()
local router = SwitchRouter({
routes = {
Foo = {
screen = {
render = function() end,
}
}
},
initialRouteName = "Foo",
defaultNavigationOptions = {
title = "FooTitle",
},
})
local screenOptions = router.getScreenOptions({
state = {
routeName = "Foo",
}
})
expect(screenOptions.title).to.equal("FooTitle")
end)
it("should correctly configure route-specified screen options", function()
local router = SwitchRouter({
routes = {
Foo = {
screen = {
render = function() end,
},
navigationOptions = { title = "RouteFooTitle" },
}
},
initialRouteName = "Foo",
defaultNavigationOptions = {
title = "FooTitle",
},
})
local screenOptions = router.getScreenOptions({
state = {
routeName = "Foo",
}
})
expect(screenOptions.title).to.equal("RouteFooTitle")
end)
it("should correctly configure component-specified screen options", function()
local router = SwitchRouter({
routes = {
Foo = {
screen = {
render = function() end,
navigationOptions = { title = "ComponentFooTitle" },
},
}
},
initialRouteName = "Foo",
defaultNavigationOptions = {
title = "FooTitle",
},
})
local screenOptions = router.getScreenOptions({
state = {
routeName = "Foo",
}
})
expect(screenOptions.title).to.equal("ComponentFooTitle")
end)
end)
describe("getActionCreators tests", function()
it("should return empty action creators table if none are provided", function()
local router = SwitchRouter({
routes = {
Foo = { render = function() end },
},
initialRouteName = "Foo",
})
local actionCreators = router.getActionCreators({ routeName = "Foo" }, "key")
local fieldCount = 0
for _ in pairs(actionCreators) do
fieldCount = fieldCount + 1
end
expect(fieldCount).to.equal(0)
end)
it("should call custom action creators function if provided", function()
local router = SwitchRouter({
routes = {
Foo = { render = function() end },
},
initialRouteName = "Foo",
getCustomActionCreators = function()
return { a = 1 }
end,
})
local actionCreators = router.getActionCreators({ routeName = "Foo" }, "key")
expect(actionCreators.a).to.equal(1)
end)
end)
describe("getComponentForState tests", function()
it("should return component matching requested state", function()
local testComponent = function() end
local router = SwitchRouter({
routes = {
Foo = { screen = testComponent },
},
initialRouteName = "Foo",
})
local component = router.getComponentForState({
routes = {
{ routeName = "Foo" },
},
index = 1,
})
expect(component).to.equal(testComponent)
end)
it("should throw if there is no route matching active index", function()
local router = SwitchRouter({
routes = {
Foo = { screen = function() end },
},
initialRouteName = "Foo",
})
expectError(function()
router.getComponentForState({
routes = {
Foo = { screen = function() end },
},
index = 2,
})
end, "There is no route defined for index '2'. " ..
"Make sure that you passed in a navigation state with a " ..
"valid tab/screen index.")
end)
it("should descend child router for requested route", function()
local testComponent = function() end
local childRouter = SwitchRouter({
routes = {
Bar = { screen = testComponent }
},
initialRouteName = "Bar",
})
local router = SwitchRouter({
routes = {
Foo = {
screen = {
render = function() end,
router = childRouter,
}
},
},
initialRouteName = "Foo",
})
local component = router.getComponentForState({
routes = {
{
routeName = "Foo",
routes = { -- Child router's routes
{ routeName = "Bar" },
},
index = 1
},
},
index = 1,
})
expect(component).to.equal(testComponent)
end)
end)
describe("getComponentForRouteName tests", function()
it("should return a component that matches the given route name", function()
local testComponent = function() end
local router = SwitchRouter({
routes = {
Foo = { screen = testComponent },
},
initialRouteName = "Foo",
})
local component = router.getComponentForRouteName("Foo")
expect(component).to.equal(testComponent)
end)
end)
describe("getStateForAction tests", function()
it("should return initial state for init action", function()
local router = SwitchRouter({
routes = {
Foo = { screen = function() end },
Bar = { screen = function() end },
},
initialRouteName = "Foo",
})
local state = router.getStateForAction(NavigationActions.init(), nil)
expect(#state.routes).to.equal(2)
expect(state.routes[state.index].routeName).to.equal("Foo")
expect(state.isTransitioning).to.equal(false)
end)
it("should adjust initial state index to match initialRouteName's index", function()
local router = SwitchRouter({
routes = {
Foo = { screen = function() end },
Bar = { screen = function() end },
},
initialRouteName = "Foo",
})
local state = router.getStateForAction(NavigationActions.init(), nil)
expect(state.routes[state.index].routeName).to.equal("Foo")
local router2 = SwitchRouter({
routes = {
Foo = { screen = function() end },
Bar = { screen = function() end },
},
initialRouteName = "Bar",
})
local state2 = router2.getStateForAction(NavigationActions.init(), nil)
expect(state2.routes[state2.index].routeName).to.equal("Bar")
end)
it("should respect optional order property", function()
local router = SwitchRouter({
routes = {
Foo = { screen = function() end },
Bar = { screen = function() end },
},
order = { "Foo", "Bar" },
initialRouteName = "Foo",
})
local state = router.getStateForAction(NavigationActions.init(), nil)
expect(state.routes[1].routeName).to.equal("Foo")
expect(state.routes[2].routeName).to.equal("Bar")
end)
it("should incorporate child router state", function()
local childRouter = SwitchRouter({
routes = {
Bar = { screen = function() end },
},
initialRouteName = "Bar",
})
local router = SwitchRouter({
routes = {
Foo = {
render = function() end,
router = childRouter,
},
City = { screen = function() end },
},
initialRouteName = "Foo",
})
local state = router.getStateForAction(NavigationActions.init(), nil)
local activeState = state.routes[state.index]
expect(activeState.routeName).to.equal("Foo") -- parent's tracking uses parent's route name
expect(activeState.routes[activeState.index].routeName).to.equal("Bar")
end)
it("should let active child handle non-init action first", function()
local childRouter = SwitchRouter({
routes = {
Bar = { screen = function() end },
City = { screen = function() end },
},
order = { "Bar", "City" },
initialRouteName = "Bar",
})
local router = SwitchRouter({
routes = {
Foo = {
render = function() end,
router = childRouter,
},
State = { render = function() end },
},
order = { "Foo", "State" },
initialRouteName = "Foo",
})
local state = router.getStateForAction(NavigationActions.navigate({ routeName = "City" }))
expect(state.routes[1].index).to.equal(2)
expect(state.index).to.equal(1)
end)
it("should go back to initial route index if BackBehavior.InitialRoute", function()
local router = SwitchRouter({
routes = {
Foo = { render = function() end },
Bar = { render = function() end },
},
order = { "Foo", "Bar" },
backBehavior = BackBehavior.InitialRoute,
initialRouteName = "Foo",
})
local prevState = {
routes = {
{ routeName = "Foo" },
{ routeName = "Bar" },
},
index = 2,
}
local newState = router.getStateForAction(NavigationActions.back(), prevState)
expect(newState.index).to.equal(1)
end)
it("should not change state on back action if BackBehavior.None", function()
local router = SwitchRouter({
routes = {
Foo = { render = function() end },
Bar = { render = function() end },
},
order = { "Foo", "Bar" },
initialRouteName = "Foo",
})
local prevState = {
routes = {
{ routeName = "Foo" },
{ routeName = "Bar" },
},
index = 2,
}
local newState = router.getStateForAction(NavigationActions.back(), prevState)
expect(newState).to.equal(prevState)
end)
it("should change active route on navigate", function()
local router = SwitchRouter({
routes = {
Foo = { render = function() end },
Bar = { render = function() end },
},
order = { "Foo", "Bar" },
initialRouteName = "Foo",
})
local newState = router.getStateForAction(NavigationActions.navigate({ routeName = "Bar" }))
expect(newState.index).to.equal(2)
expect(newState.routes[newState.index].routeName).to.equal("Bar")
end)
it("should pass sub-action to child router on navigate", function()
local childRouter = SwitchRouter({
routes = {
City = { screen = function() end },
State = { screen = function() end },
},
initialRouteName = "City",
})
local router = SwitchRouter({
routes = {
Foo = { render = function() end },
Bar = {
render = function() end,
router = childRouter,
},
},
initialRouteName = "Foo",
})
local newState = router.getStateForAction(NavigationActions.navigate({
routeName = "Bar",
action = NavigationActions.navigate({ routeName = "State" }),
}))
local activeRoute = newState.routes[newState.index]
expect(activeRoute.routeName).to.equal("Bar")
expect(activeRoute.routes[activeRoute.index].routeName).to.equal("State")
end)
it("should return initial state if navigating to active child without previous state", function()
local childRouter = SwitchRouter({
routes = {
Bar = { screen = function() end },
},
initialRouteName = "Bar",
})
local router = SwitchRouter({
routes = {
Foo = {
render = function() end,
router = childRouter,
},
City = { render = function() end },
},
initialRouteName = "Foo",
})
local newState = router.getStateForAction(NavigationActions.navigate({
routeName = "Foo",
}))
expect(newState.routes[newState.index].routeName).to.equal("Foo")
expect(newState.isTransitioning).to.equal(false)
end)
it("should reset state for deactivated route by default", function()
local router = SwitchRouter({
routes = {
Foo = { render = function() end },
Bar = { render = function() end },
},
order = { "Foo", "Bar" },
initialRouteName = "Foo",
})
local initialState = {
routes = {
{ routeName = "Foo", params = { a = 1 } },
{ routeName = "Bar" },
},
index = 1,
}
local state = router.getStateForAction(NavigationActions.navigate({ routeName = "Bar" }), initialState)
expect(state.routes[1].params.a).to.equal(nil) -- should be empty
end)
it("should not reset state for deactivated route if resetOnBlur is false", function()
local router = SwitchRouter({
routes = {
Foo = { render = function() end },
Bar = { render = function() end },
},
order = { "Foo", "Bar" },
initialRouteName = "Foo",
resetOnBlur = false,
})
local testParams = { a = 1 }
local initialState = {
routes = {
{ routeName = "Foo", params = testParams },
{ routeName = "Bar" },
},
index = 1,
}
local state = router.getStateForAction(NavigationActions.navigate({ routeName = "Bar" }), initialState)
expect(state.routes[1].params).to.equal(testParams)
end)
it("should set params on route for setParams action", function()
local router = SwitchRouter({
routes = {
Foo = { render = function() end },
Bar = { render = function() end },
},
initialRouteName = "Foo",
})
local newState = router.getStateForAction(NavigationActions.setParams({
key = "Foo", -- By default, key == routeName
params = { a = 1 },
}))
expect(newState.routes[newState.index].params.a).to.equal(1)
end)
it("should preserve route configured params for child router", function()
local childRouter = SwitchRouter({
routes = {
Bar = {
screen = function() end,
params = { a = 2 },
},
},
initialRouteName = "Bar",
})
local router = SwitchRouter({
routes = {
Foo = {
render = function() end,
params = { a = 1 },
router = childRouter,
},
City = { render = function() end },
},
initialRouteName = "Foo",
})
local state = router.getStateForAction(NavigationActions.init())
expect(state.routes[state.index].params.a).to.equal(1)
end)
it("should merge initialRouteParams with initial route's own params", function()
local router = SwitchRouter({
routes = {
Foo = {
render = function() end,
params = { a = 1 },
},
Bar = {
render = function() end,
params = { a = 1 },
},
},
order = { "Foo", "Bar" },
initialRouteName = "Foo",
initialRouteParams = { a = 2, b = 3 },
})
local state = router.getStateForAction(NavigationActions.init())
expect(state.routes[1].params.a).to.equal(2)
expect(state.routes[1].params.b).to.equal(3)
expect(state.routes[2].params.a).to.equal(1)
expect(state.routes[2].params.b).to.equal(nil)
end)
it("should merge init action params with initial route's own params and initialRouteParams", function()
local router = SwitchRouter({
routes = {
Foo = { render = function() end, params = { a = 1 } }
},
initialRouteName = "Foo",
initialRouteParams = { c = 3 },
})
local state = router.getStateForAction(NavigationActions.init({ params = { b = 2 } }))
expect(state.routes[1].params.a).to.equal(1)
expect(state.routes[1].params.b).to.equal(2)
expect(state.routes[1].params.c).to.equal(3)
end)
it("should merge navigate action params for child router", function()
local childRouter = SwitchRouter({
routes = {
Bar = {
screen = function() end,
params = { a = 2 },
},
},
initialRouteName = "Bar",
})
local router = SwitchRouter({
routes = {
Foo = {
render = function() end,
router = childRouter,
},
},
initialRouteName = "Foo",
})
local state = router.getStateForAction(NavigationActions.navigate({
routeName = "Bar",
params = { b = 3 },
}))
expect(state.routes[1].routes[1].params.a).to.equal(2)
expect(state.routes[1].routes[1].params.b).to.equal(3)
end)
it("should propagate a child router getStateForAction failure to caller", function()
local childRouter = SwitchRouter({
routes = {
Bar = { screen = function() end },
},
initialRouteName = "Bar",
})
local router = SwitchRouter({
routes = {
Foo = {
render = function() end,
router = childRouter,
},
},
initialRouteName = "Foo",
})
-- need to properly initialize state because we're being abusive of getStateForAction
local initialState = router.getStateForAction(NavigationActions.init())
childRouter.getStateForAction = function() return nil end
local state = router.getStateForAction(NavigationActions.navigate("Bar"), initialState)
expect(state).to.equal(nil)
end)
end)
end
@@ -0,0 +1,67 @@
return function()
local TabRouter = require(script.Parent.Parent.TabRouter)
local NavigationActions = require(script.Parent.Parent.Parent.NavigationActions)
-- NOTE: Most functional tests are covered by SwitchRouter.spec.lua
-- We just check that we can mount a basic case, and check that our custom
-- defaults for resetOnBlur and backBehavior work as expected.
it("should return a component that matches the given route name", function()
local testComponent = function() end
local router = TabRouter({
routes = {
Foo = { screen = testComponent },
},
initialRouteName = "Foo",
})
local component = router.getComponentForRouteName("Foo")
expect(component).to.equal(testComponent)
end)
it("should not reset state for deactivated route", function()
local router = TabRouter({
routes = {
Foo = { render = function() end },
Bar = { render = function() end },
},
order = { "Foo", "Bar" },
initialRouteName = "Foo",
})
local testParams = { a = 1 }
local initialState = {
routes = {
{ routeName = "Foo", params = testParams },
{ routeName = "Bar" },
},
index = 1,
}
local state = router.getStateForAction(NavigationActions.navigate({ routeName = "Bar" }), initialState)
expect(state.routes[1].params).to.equal(testParams)
end)
it("should go back to initial route index", function()
local router = TabRouter({
routes = {
Foo = { render = function() end },
Bar = { render = function() end },
},
order = { "Foo", "Bar" },
initialRouteName = "Foo",
})
local prevState = {
routes = {
{ routeName = "Foo" },
{ routeName = "Bar" },
},
index = 2,
}
local newState = router.getStateForAction(NavigationActions.back(), prevState)
expect(newState.index).to.equal(1)
end)
end
@@ -0,0 +1,115 @@
return function()
local createConfigGetter = require(script.Parent.Parent.createConfigGetter)
local Roact = require(script.Parent.Parent.Parent.Parent.Roact)
it("should return a function", function()
local result = createConfigGetter({}, {})
expect(type(result)).to.equal("function")
end)
it("should return a screen config when called", function()
local HomeScreen = Roact.Component:extend("HomeScreen")
HomeScreen.navigationOptions = function(props)
local username = props.navigation.state.params and
props.navigation.state.params.user or "anonymous"
return {
title = string.format("Welcome %s", username),
gesturesEnabled = true,
}
end
function HomeScreen:render() return nil end
local SettingsScreen = Roact.Component:extend("SettingsScreen")
SettingsScreen.navigationOptions = {
title = "Settings!!!",
gesturesEnabled = false,
}
function SettingsScreen:render() return nil end
local NotificationScreen = Roact.Component:extend("NotificationScreen")
NotificationScreen.navigationOptions = function(props)
local gesturesEnabled = true
if props.navigation.state.params then
gesturesEnabled = not props.navigation.state.params.fullscreen
end
return {
title = "42",
gesturesEnabled = gesturesEnabled
}
end
local getScreenOptions = createConfigGetter({
Home = { screen = HomeScreen },
Settings = { screen = SettingsScreen },
Notifications = {
screen = NotificationScreen,
navigationOptions = {
title = "10 new notifications",
}
}
})
local routes = {
{ key = "A", routeName = "Home", },
{ key = "B", routeName = "Home", params = { user = "jane"} },
{ key = "C", routeName = "Settings", },
{ key = "D", routeName = "Notifications", },
{ key = "E", routeName = "Notifications", params = { fullscreen = true } },
}
expect(getScreenOptions({ state = routes[1] }, {}).title
).to.equal("Welcome anonymous")
expect(getScreenOptions({ state = routes[2] }, {}).title
).to.equal("Welcome jane")
expect(getScreenOptions({ state = routes[1] }, {}).gesturesEnabled
).to.equal(true)
expect(getScreenOptions({ state = routes[3] }, {}).title
).to.equal("Settings!!!")
expect(getScreenOptions({ state = routes[3] }, {}).gesturesEnabled
).to.equal(false)
expect(getScreenOptions({ state = routes[4] }, {}).title
).to.equal("10 new notifications")
expect(getScreenOptions({ state = routes[4] }, {}).gesturesEnabled
).to.equal(true)
expect(getScreenOptions({ state = routes[5] }, {}).gesturesEnabled
).to.equal(false)
end)
it("should override default config with component-specific config", function()
local getScreenOptions = createConfigGetter({
Home = {
screen = {
render = function() end,
navigationOptions = { title = "ComponentHome" },
},
},
defaultNavigationOptions = { title = "DefaultTitle" },
})
expect(getScreenOptions({ state = { routeName = "Home" } }).title).to.equal("ComponentHome")
end)
it("should override component-specific config with route-specific config", function()
local getScreenOptions = createConfigGetter({
Home = {
screen = {
render = function() end,
navigationOptions = { title = "ComponentHome" },
},
navigationOptions = { title = "RouteHome" },
},
defaultNavigationOptions = { title = "DefaultTitle" },
})
expect(getScreenOptions({ state = { routeName = "Home" } }).title).to.equal("RouteHome")
end)
end
@@ -0,0 +1,60 @@
return function()
local getChildRouter = require(script.Parent.Parent.getChildRouter)
it("should throw if router is not a table", function()
local status, err = pcall(function()
getChildRouter(5, "myRoute")
end)
expect(status).to.equal(false)
expect(string.find(err, "router must be a table")).to.never.equal(nil)
end)
it("should throw if routeName is not a string", function()
local status, err = pcall(function()
getChildRouter({}, 5)
end)
expect(status).to.equal(false)
expect(string.find(err, "routeName must be a string")).to.never.equal(nil)
end)
it("should return child router if found", function()
local childRouter = {}
local result = getChildRouter({
childRouters = {
myRoute = childRouter,
}
}, "myRoute")
expect(result).to.equal(childRouter)
end)
it("should look up component router if no child router is found", function()
local component = { router = {} }
local result = getChildRouter({
getComponentForRouteName = function(routeName)
if routeName == "myRoute" then
return component
else
return nil
end
end
}, "myRoute")
expect(result).to.equal(component.router)
end)
it("should throw if no child routers are specified and getComponentForRouteName is not a function", function()
local status, err = pcall(function()
getChildRouter({
getComponentForRouteName = 5
}, "myRoute")
end)
expect(status).to.equal(false)
expect(string.find(err, "router.getComponentForRouteName must be a function if no child routers are specified")
).to.never.equal(nil)
end)
end
@@ -0,0 +1,101 @@
return function()
local getNavigationActionCreators = require(script.Parent.Parent.getNavigationActionCreators)
local NavigationActions = require(script.Parent.Parent.Parent.NavigationActions)
local function expectError(functor, msg)
local status, err = pcall(functor)
expect(status).to.equal(false)
expect(string.find(err, msg)).to.never.equal(nil)
end
it("should return a table with correct functions when called", function()
local result = getNavigationActionCreators()
expect(type(result.goBack)).to.equal("function")
expect(type(result.navigate)).to.equal("function")
expect(type(result.setParams)).to.equal("function")
end)
describe("goBack tests", function()
it("should return a Back action when called", function()
local result = getNavigationActionCreators().goBack("theKey")
expect(result.type).to.equal(NavigationActions.Back)
expect(result.key).to.equal("theKey")
end)
it("should throw when route.key is not a string", function()
expectError(function()
getNavigationActionCreators({ key = 5 }).goBack()
end, "%.goBack%(%): key should be a string")
end)
it("should fall back to route.key if key is not provided", function()
local result = getNavigationActionCreators({ key = "routeKey" }).goBack()
expect(result.key).to.equal("routeKey")
end)
it("should override route.key if key is provided", function()
local result = getNavigationActionCreators({ key = "routeKey" }).goBack("theKey")
expect(result.key).to.equal("theKey")
end)
end)
describe("navigate tests", function()
it("should return a Navigate action when called", function()
local theParams = {}
local childAction = {}
local result = getNavigationActionCreators().navigate("theRoute", theParams, childAction)
expect(result.type).to.equal(NavigationActions.Navigate)
expect(result.routeName).to.equal("theRoute")
expect(result.params).to.equal(theParams)
expect(result.action).to.equal(childAction)
end)
it("should return a navigate action with matching properties when called with a table", function()
local testNavigateTo = {
routeName = "theRoute",
params = {},
action = {},
}
local result = getNavigationActionCreators().navigate(testNavigateTo)
expect(result.type).to.equal(NavigationActions.Navigate)
expect(result.routeName).to.equal("theRoute")
expect(result.params).to.equal(testNavigateTo.params)
expect(result.action).to.equal(testNavigateTo.action)
end)
it("should throw when navigateTo is not a valid type", function()
expectError(function()
getNavigationActionCreators().navigate(5)
end, "%.navigate%(%): navigateTo must be a string or table")
end)
it("should throw when params is provided with a table navigateTo", function()
expectError(function()
getNavigationActionCreators().navigate({}, {})
end, "%.navigate%(%): params can only be provided with a string navigateTo value")
end)
it("should throw when action is provided with a table navigateTo", function()
expectError(function()
getNavigationActionCreators().navigate({}, nil, {})
end, "%.navigate%(%): child action can only be provided with a string navigateTo value")
end)
end)
describe("setParams tests", function()
it("should return a SetParams action when called", function()
local theParams = {}
local result = getNavigationActionCreators({ key = "theKey" }).setParams(theParams)
expect(result.type).to.equal(NavigationActions.SetParams)
expect(result.key).to.equal("theKey")
expect(result.params).to.equal(theParams)
end)
it("should throw when called by a root navigator", function()
expectError(function()
getNavigationActionCreators({}).setParams({})
end, "%.setParams%(%): cannot be called by the root navigator")
end)
end)
end
@@ -0,0 +1,89 @@
return function()
local getScreenForRouteName = require(script.Parent.Parent.getScreenForRouteName)
it("should throw for invalid arg types", function()
local status, err = pcall(function()
getScreenForRouteName("", "myRoute")
end)
expect(status).to.equal(false)
expect(string.find(err, "routeConfigs must be a table")).to.never.equal(nil)
status, err = pcall(function()
getScreenForRouteName({}, 5)
end)
expect(status).to.equal(false)
expect(string.find(err, "routeName must be a string")).to.never.equal(nil)
end)
it("should throw if requested route is not present within table", function()
local status, err = pcall(function()
getScreenForRouteName({
notMyRoute = function() return "foo" end
}, "myRoute")
end)
expect(status).to.equal(false)
expect(string.find(err, "There is no route defined for key 'myRoute'.")).to.never.equal(nil)
end)
it("should return raw table if screen and getScreen are not props", function()
local screenComponent = { render = function() return nil end }
local result = getScreenForRouteName({
myRoute = screenComponent
}, "myRoute")
expect(result).to.equal(screenComponent)
end)
it("should return screen prop if it is set in route data table", function()
local screenComponent = { render = function() return nil end }
local result = getScreenForRouteName({
myRoute = {
screen = screenComponent
}
}, "myRoute")
expect(result).to.equal(screenComponent)
end)
it("should return object returned by getScreen function if object is valid Roact element", function()
local screenComponent = { render = function() return nil end }
local result = getScreenForRouteName({
myRoute = {
getScreen = function() return screenComponent end
}
}, "myRoute")
expect(result).to.equal(screenComponent)
end)
it("should throw if getScreen does not return a valid Roact element", function()
local status, err = pcall(function()
getScreenForRouteName({
myRoute = {
getScreen = function() return nil end
}
}, "myRoute")
end)
expect(status).to.equal(false)
expect(string.find(err, "The getScreen function defined for route 'myRoute'" ..
" did not return a valid screen or navigator")).to.never.equal(nil)
end)
it("should throw if screen is not a valid Roact element", function()
local status, err = pcall(function()
getScreenForRouteName({
myRoute = {
screen = 5,
}
}, "myRoute")
end)
expect(status).to.equal(false)
expect(string.find(err, "screen param for key 'myRoute' must be a valid Roact component.")).to.never.equal(nil)
end)
end
@@ -0,0 +1,119 @@
return function()
local Roact = require(script.Parent.Parent.Parent.Parent.Roact)
local validateRouteConfigMap = require(script.Parent.Parent.validateRouteConfigMap)
local function expectError(functor, msg)
local status, err = pcall(functor)
expect(status).to.equal(false)
expect(string.find(err, msg)).to.never.equal(nil)
end
local TestComponent = Roact.Component:extend("TestComponent")
function TestComponent:render()
return nil
end
it("should throw if routeConfigs is not a table", function()
expectError(function()
validateRouteConfigMap(5)
end, "routeConfigs must be a table")
end)
it("should throw if routeConfigs is empty", function()
expectError(function()
validateRouteConfigMap({})
end, "Please specify at least one route when configuring a navigator%.")
end)
it("should throw if routeConfigs contains an invalid Roact element", function()
expectError(function()
validateRouteConfigMap({
myRoute = 5,
})
end, "The component for route 'myRoute' must be a Roact Function/Stateful component or table with 'getScreen'." ..
"getScreen function must return Roact Function/Stateful component.")
end)
it("should throw if getScreen returns invalid Roact element", function()
expectError(function()
validateRouteConfigMap({
myRoute = {
getScreen = function() end,
},
})
end, "The component for route 'myRoute' must be a Roact Function/Stateful component or table with 'getScreen'." ..
"getScreen function must return Roact Function/Stateful component.")
end)
it("should throw when both screen and getScreen are provided for same component", function()
expectError(function()
validateRouteConfigMap({
myRoute = {
screen = "TheScreen",
getScreen = function() return TestComponent end,
}
})
end, "Route 'myRoute' should provide 'screen' or 'getScreen', but not both%.")
end)
it("should throw for a simple table where screen is not a Roact Function/Stateful component", function()
expectError(function()
validateRouteConfigMap({
myRoute = {
screen = {},
}
})
end, "The component for route 'myRoute' must be a Roact Function/Stateful component or table with 'getScreen'." ..
"getScreen function must return Roact Function/Stateful component.")
end)
it("should throw for a non-function getScreen", function()
expectError(function()
validateRouteConfigMap({
myRoute = {
getScreen = 5
}
})
end, "The component for route 'myRoute' must be a Roact Function/Stateful component or table with 'getScreen'." ..
"getScreen function must return Roact Function/Stateful component.")
end)
it("should throw for a Host Component", function()
expectError(function()
validateRouteConfigMap({
myRoute = {
aFrame = "Frame"
}
})
end, "The component for route 'myRoute' must be a Roact Function/Stateful component or table with 'getScreen'." ..
"getScreen function must return Roact Function/Stateful component.")
end)
it("should pass for valid basic routeConfigs", function()
validateRouteConfigMap({
basicComponentRoute = TestComponent,
functionalComponentRoute = function() end,
})
end)
it("should pass for valid screen prop type routeConfigs", function()
validateRouteConfigMap({
basicComponentRoute = {
screen = TestComponent,
},
functionalComponentRoute = {
screen = function() end,
},
})
end)
it("should pass for valid getScreen route configs", function()
validateRouteConfigMap({
getScreenRoute = {
getScreen = function() return TestComponent end,
}
})
end)
end
@@ -0,0 +1,25 @@
return function()
local validateScreenOptions = require(script.Parent.Parent.validateScreenOptions)
it("should not throw when there are no problems", function()
validateScreenOptions({ title = "foo" }, { routeName = "foo" })
end)
it("should throw error if no routeName is provided", function()
local status, err = pcall(function()
validateScreenOptions({ title = "bar" }, {})
end)
expect(status).to.equal(false)
expect(string.find(err, "route.routeName must be a string")).to.never.equal(nil)
end)
it("should throw error for options with function for title", function()
expect(function()
validateScreenOptions({
title = function() end,
}, { routeName = "foo" })
end).to.throw()
end)
end
@@ -0,0 +1,53 @@
local Cryo = require(script.Parent.Parent.Parent.Cryo)
local getScreenForRouteName = require(script.Parent.getScreenForRouteName)
local validateScreenOptions = require(script.Parent.validateScreenOptions)
local validate = require(script.Parent.Parent.utils.validate)
local function applyConfig(configurer, navigationOptions, configProps)
navigationOptions = navigationOptions or {}
local configurerType = type(configurer)
if configurerType == "function" then
return Cryo.Dictionary.join(navigationOptions,
configurer(Cryo.Dictionary.join(configProps or {}, {
navigationOptions = navigationOptions
})))
elseif configurerType == "table" then
return Cryo.Dictionary.join(navigationOptions, configurer)
else
return navigationOptions
end
end
return function(routeConfigs, navigatorScreenConfig)
return function(navigation, screenProps)
screenProps = screenProps or {}
local route = navigation.state
validate(type(route) == "table", "navigation.state must be a table")
validate(type(route.routeName == "string"), "routeName must be a string")
local component = getScreenForRouteName(routeConfigs, route.routeName)
local routeConfig = routeConfigs[route.routeName]
local routeScreenConfig = nil
if routeConfig ~= component then
routeScreenConfig = routeConfig.navigationOptions
end
local componentScreenConfig = type(component) == "table"
and component.navigationOptions or {}
local configOptions = {
navigation = navigation,
screenProps = screenProps,
}
local outputConfig = applyConfig(navigatorScreenConfig, {}, configOptions)
outputConfig = applyConfig(componentScreenConfig, outputConfig, configOptions)
outputConfig = applyConfig(routeScreenConfig, outputConfig, configOptions)
validateScreenOptions(outputConfig, route)
return outputConfig
end
end
@@ -0,0 +1,20 @@
local validate = require(script.Parent.Parent.utils.validate)
return function(router, routeName)
validate(type(router) == "table", "router must be a table")
validate(type(routeName) == "string", "routeName must be a string")
if router.childRouters and router.childRouters[routeName] then
return router.childRouters[routeName]
end
validate(type(router.getComponentForRouteName) == "function",
"router.getComponentForRouteName must be a function if no child routers are specified")
local component = router.getComponentForRouteName(routeName)
if type(component) == "table" then
return component.router
else
return nil
end
end
@@ -0,0 +1,42 @@
local NavigationActions = require(script.Parent.Parent.NavigationActions)
local validate = require(script.Parent.Parent.utils.validate)
return function(route)
local result = {}
-- Go back FROM screen identified by 'key', or the default for current route.
function result.goBack(key)
if key == nil and route.key then
validate(type(route.key) == "string", ".goBack(): key should be a string")
key = route.key
end
return NavigationActions.back({ key = key })
end
-- Navigate to a different screen, either by route name+params+action, or
-- by passing a raw navigation table.
function result.navigate(navigateTo, params, action)
if type(navigateTo) == "string" then
return NavigationActions.navigate({
routeName = navigateTo,
params = params,
action = action,
})
else
validate(type(navigateTo) == "table", ".navigate(): navigateTo must be a string or table")
validate(params == nil, ".navigate(): params can only be provided with a string navigateTo value")
validate(action == nil, ".navigate(): child action can only be provided with a string navigateTo value")
return NavigationActions.navigate(navigateTo)
end
end
-- Change navigation params for current route
function result.setParams(params)
validate(type(route.key) == "string", ".setParams(): cannot be called by the root navigator")
return NavigationActions.setParams({ params = params, key = route.key })
end
return result
end
@@ -0,0 +1,32 @@
local validate = require(script.Parent.Parent.utils.validate)
local isValidScreenComponent = require(script.Parent.Parent.utils.isValidScreenComponent)
-- Extract a single screen Roact component/navigator from
-- a navigator's config.
return function(routeConfigs, routeName)
validate(type(routeConfigs) == "table", "routeConfigs must be a table")
validate(type(routeName) == "string", "routeName must be a string")
local routeConfig = routeConfigs[routeName]
validate(routeConfig ~= nil, "There is no route defined for key '%s'.", routeName)
local routeConfigType = type(routeConfig)
if routeConfigType == "table" then
if routeConfig.screen ~= nil then
validate(isValidScreenComponent(routeConfig.screen),
"screen param for key '%s' must be a valid Roact component.", routeName)
return routeConfig.screen
elseif type(routeConfig.getScreen) == "function" then
local screen = routeConfig.getScreen()
validate(isValidScreenComponent(screen),
"The getScreen function defined for route '%s' did not return a valid screen or navigator", routeName)
return screen
end
end
validate(isValidScreenComponent(routeConfig),
"Value for key '%s' must be a route config table or a valid Roact component.", routeName)
return routeConfig
end
@@ -0,0 +1,44 @@
local validate = require(script.Parent.Parent.utils.validate)
local isValidScreenComponent = require(script.Parent.Parent.utils.isValidScreenComponent)
--[[
This utility checks to make sure that configs passed to a
router are in the correct format.
Example:
routeConfigs = {
routeNameEx1 = Roact.Function/Stateful_Component,
routeNameEx2 = {
screen = Roact.Function/Stateful_Component,
},
routeNameEx3 = {
getScreen = function()
return Roact.Function/Stateful_Component
end
}
routeNameEx4 = AnotherRoactNavigator -- this is a Stateful Component
}
]]
return function(routeConfigs)
validate(type(routeConfigs) == "table", "routeConfigs must be a table")
local atLeastOne = false
for routeName, routeConfig in pairs(routeConfigs) do
local configIsTable = type(routeConfig) == "table" or false
local screenConfig = configIsTable and routeConfig or {} -- easy index .screen/.getScreen
local screenComponent = configIsTable and routeConfig.screen or routeConfig
validate(isValidScreenComponent(screenComponent) or
(type(screenConfig.getScreen) == "function" and isValidScreenComponent(screenConfig.getScreen())),
"The component for route '%s' must be a Roact Function/Stateful component or table with 'getScreen'." ..
"getScreen function must return Roact Function/Stateful component.",
routeName)
validate(screenConfig.screen == nil or screenConfig.getScreen == nil,
"Route '%s' should provide 'screen' or 'getScreen', but not both.", routeName)
atLeastOne = true
end
validate(atLeastOne, "Please specify at least one route when configuring a navigator.")
return routeConfigs
end
@@ -0,0 +1,10 @@
local validate = require(script.Parent.Parent.utils.validate)
return function(screenOptions, route)
validate(type(screenOptions) == "table", "screenOptions must be a table")
validate(type(route) == "table", "route must be a table")
validate(type(route.routeName) == "string", "route.routeName must be a string")
validate(type(screenOptions.title) ~= "function",
"title cannot be defined as a function in navigation options for screen '%s'",
route.routeName)
end
@@ -0,0 +1,19 @@
local uniqueBaseId = "id-" .. tostring(math.random(100000, 1000000))
local uuidCount = 0
local KeyGenerator = {}
-- NOTE: FOR TESTING ONLY.
-- Normalize keys so that tests can be consistent.
function KeyGenerator.normalizeKeys()
uniqueBaseId = "id-test-"
uuidCount = 0
end
-- Get a string key that is unique for this session.
function KeyGenerator.generateKey()
uuidCount = uuidCount + 1
return uniqueBaseId .. tostring(uuidCount)
end
return KeyGenerator
@@ -0,0 +1,36 @@
local validate = require(script.Parent.validate)
local PageNavigationEvent = {}
PageNavigationEvent.__index = PageNavigationEvent
function PageNavigationEvent.new(pageName, event)
validate(typeof(pageName) == "string", "pageName should be string")
validate(typeof(event) == "userdata", "event should be RoactNavigation.Event")
local self = {
event = event,
pageName = pageName,
}
setmetatable(self, PageNavigationEvent)
return self
end
function PageNavigationEvent.isPageNavigationEvent(instance)
return getmetatable(instance).__index == PageNavigationEvent
end
PageNavigationEvent.__tostring = function (pageNavigationEvent)
return string.format( "%-15s - %s", tostring(pageNavigationEvent.event), pageNavigationEvent.pageName)
end
function PageNavigationEvent:equalTo(anotherPageNavigationEvent)
validate(PageNavigationEvent.isPageNavigationEvent(anotherPageNavigationEvent), "should be PageNavigationEvent")
return self.pageName == anotherPageNavigationEvent.pageName and self.event == anotherPageNavigationEvent.event
end
return PageNavigationEvent
@@ -0,0 +1,255 @@
--[[
Provides functions for comparing and printing lua tables.
]]
local TableUtilities = {}
local defaultIgnore = {}
local function makeKeyString(key)
if type(key) == "string" then
return string.format("%s", key)
else
return string.format("[%s]", tostring(key))
end
end
local function makeValueString(value)
local valueType = type(value)
if valueType == "string" then
return string.format("%q", value)
elseif valueType == "function" or valueType == "table" then
return string.format("<%s>", tostring(value))
else
return string.format("%s", tostring(value))
end
end
local function printKeypair(key, value, indentStr, comment)
local keyString = makeKeyString(key)
local valueString = makeValueString(value)
local commentStr = comment and string.format(" -- %s", comment) or ""
print(string.format("%s%s = %s,%s", indentStr, keyString, valueString, commentStr))
end
--[[
Takes two tables A and B, returns if they have the same key-value pairs
Except ignored keys
]]
function TableUtilities.ShallowEqual(A, B, ignore)
if not A or not B then
return false
elseif A == B then
return true
end
if not ignore then
ignore = defaultIgnore
end
for key, value in pairs(A) do
if B[key] ~= value and not ignore[key] then
return false
end
end
for key, value in pairs(B) do
if A[key] ~= value and not ignore[key] then
return false
end
end
return true
end
--[[
Takes two tables A, B and a key, returns if two tables have the same value at key
]]
function TableUtilities.EqualKey(A, B, key)
if A and B and key and key ~= "" and A[key] and B[key] and A[key] == B[key] then
return true
end
return false
end
--[[
Takes two tables A and B, returns a new table with elements of A
which are either not keys in B or have a different value in B
]]
function TableUtilities.TableDifference(A, B)
local new = {}
for key, value in pairs(A) do
if B[key] ~= A[key] then
new[key] = value
end
end
return new
end
--[[
Takes a list and returns a table whose
keys are elements of the list and whose
values are all true
]]
local function membershipTable(list)
local result = {}
for i = 1, #list do
result[list[i]] = true
end
return result
end
--[[
Takes a table and returns a list of keys in that table
]]
local function listOfKeys(t)
local result = {}
for key,_ in pairs(t) do
table.insert(result, key)
end
return result
end
--[[
Takes two lists A and B, returns a new list of elements of A
which are not in B
]]
function TableUtilities.ListDifference(A, B)
return listOfKeys(TableUtilities.TableDifference(membershipTable(A), membershipTable(B)))
end
--[[
For debugging. Returns false if the given table has any of the following:
- a key that is neither a number or a string
- a mix of number and string keys
- number keys which are not exactly 1..#t
]]
function TableUtilities.CheckListConsistency(t)
local containsNumberKey = false
local containsStringKey = false
local numberConsistency = true
local index = 1
for x, _ in pairs(t) do
if type(x) == 'string' then
containsStringKey = true
elseif type(x) == 'number' then
if index ~= x then
numberConsistency = false
end
containsNumberKey = true
else
return false
end
if containsStringKey and containsNumberKey then
return false
end
index = index + 1
end
if containsNumberKey then
return numberConsistency
end
return true
end
--[[
For debugging, serializes the given table to a reasonable string that might even interpret as lua.
]]
function TableUtilities.RecursiveToString(t, indent)
indent = indent or ''
if type(t) == 'table' then
local result = ""
if not TableUtilities.CheckListConsistency(t) then
result = result .. "-- WARNING: this table fails the list consistency test\n"
end
result = result .. "{\n"
for k,v in pairs(t) do
if type(k) == 'string' then
result = result
.. " "
.. indent
.. tostring(k)
.. " = "
.. TableUtilities.RecursiveToString(v, " "..indent)
..";\n"
end
if type(k) == 'number' then
result = result .. " " .. indent .. TableUtilities.RecursiveToString(v, " "..indent)..",\n"
end
end
result = result .. indent .. "}"
return result
else
return tostring(t)
end
end
--[[
For debugging. Prints the table on multiple lines to overcome log-line length
limitations which are otherwise necessary for performance. Use sparingly.
]]
function TableUtilities.Print(t, indent)
indent = indent or ' '
if type(t) ~= "table" then
error("TableUtilities.Print must be passed a table", 2)
end
-- For cycle detection
local printedTables = {}
local function recurse(subTable, tableKey, level)
-- Prevent cycles by keeping track of what tables we have printed
printedTables[subTable] = true
local indentStr = string.rep(indent, level)
local valueIndentStr = string.rep(indent, level + 1)
if tableKey then
print(string.format("%s%s = %s {", indentStr, makeKeyString(tableKey), makeValueString(subTable)))
else
print(string.format("%s%s {", indentStr, makeValueString(subTable)))
end
for key, value in pairs(subTable) do
if type(value) == "table" then
if printedTables[value] then
printKeypair(key, value, valueIndentStr, "Possible cycle")
else
recurse(value, key, level + 1)
end
else
printKeypair(key, value, valueIndentStr)
end
end
print(string.format("%s}%s", indentStr, (level > 0 and "," or "")))
end
recurse(t, nil, 0)
end
--[[
Takes a table and returns the field count
]]
function TableUtilities.FieldCount(t)
local fieldCount = 0
for _ in pairs(t) do
fieldCount = fieldCount + 1
end
return fieldCount
end
return TableUtilities
@@ -0,0 +1,72 @@
local Roact = require(script.Parent.Parent.Parent.Roact)
local RoactNavigation = require(script.Parent.Parent)
local NavigationEventsAdapter = require(script.Parent.Parent.views.NavigationEventsAdapter)
local validate = require(script.Parent.validate)
local PageNavigationEvent = require(script.Parent.PageNavigationEvent)
local TrackNavigationEvents = {}
TrackNavigationEvents.__index = TrackNavigationEvents
function TrackNavigationEvents.new()
local self = {
navigationEvents = {},
}
setmetatable(self, TrackNavigationEvents)
return self
end
function TrackNavigationEvents:getNavigationEvents()
return self.navigationEvents
end
function TrackNavigationEvents:printNavigationEvents()
print("Total Events: ", #self.navigationEvents)
for _, navigationEvent in ipairs(self.navigationEvents) do
print(navigationEvent)
end
end
function TrackNavigationEvents:waitForNumberEventsMaxWaitTime(numberOfEvents, maxWaitTimeInSeconds)
local secondsWaitedFor = 0
local waitDurationPerIteration = 0.33
while #self.navigationEvents < numberOfEvents
and secondsWaitedFor <= maxWaitTimeInSeconds
do
wait(waitDurationPerIteration)
-- print("waiting for number of events to reach:", numberOfEvents, "waited for:", secondsWaitedFor)
secondsWaitedFor = secondsWaitedFor + waitDurationPerIteration
end
end
function TrackNavigationEvents:resetNavigationEvents()
self.navigationEvents = {}
end
function TrackNavigationEvents:createNavigationAdapter(pageName, components)
local events = {}
for _, event in pairs(RoactNavigation.Events) do
events[event] = function()
PageNavigationEvent.new(pageName, event)
table.insert(self.navigationEvents, PageNavigationEvent.new(pageName, event))
end
end
return Roact.createElement(NavigationEventsAdapter, events, components)
end
function TrackNavigationEvents:equalTo(pageNavigationEventList)
validate(typeof(pageNavigationEventList) == "table", "should be a list")
local numberOfEvents = #self.navigationEvents
local equal = numberOfEvents == #pageNavigationEventList
local eventIndex = 1
while eventIndex <= numberOfEvents and equal do
equal = self.navigationEvents[eventIndex]:equalTo(pageNavigationEventList[eventIndex])
eventIndex = eventIndex + 1
end
return equal
end
return TrackNavigationEvents
@@ -0,0 +1,14 @@
return function()
local KeyGenerator = require(script.Parent.Parent.KeyGenerator)
it("should generate a new string key when called", function()
KeyGenerator.normalizeKeys()
expect(KeyGenerator.generateKey()).to.equal("id-test-1")
expect(KeyGenerator.generateKey()).to.equal("id-test-2")
end)
it("should generate unique string keys without being normalized", function()
expect(KeyGenerator.generateKey()).to.never.equal(KeyGenerator.generateKey())
end)
end
@@ -0,0 +1,43 @@
local RoactNavigation = require(script.Parent.Parent.Parent)
local PageNavigationEvent = require(script.Parent.Parent.PageNavigationEvent)
return function()
local testPage = "TEST PAGE"
local willFocusEvent = RoactNavigation.Events.WillFocus
it("should validate constructor inputs", function()
expect(function () PageNavigationEvent.new(testPage, willFocusEvent) end).never.to.throw()
expect(function () PageNavigationEvent.new(testPage, 1) end).to.throw()
expect(function () PageNavigationEvent.new(testPage, "event") end).to.throw()
expect(function () PageNavigationEvent.new(testPage, nil) end).to.throw()
expect(function () PageNavigationEvent.new(testPage, {some = "junk"}) end).to.throw()
expect(function () PageNavigationEvent.new(1, willFocusEvent) end).to.throw()
expect(function () PageNavigationEvent.new(nil, willFocusEvent) end).to.throw()
expect(function () PageNavigationEvent.new({"bogus"}, willFocusEvent) end).to.throw()
end)
it("should be constructed from page name and RoactNavigation.Events", function()
for _, event in pairs(RoactNavigation.Events) do
local pageName = testPage .. tostring(event)
local testPageNavigationEvent = PageNavigationEvent.new(pageName, event)
expect(testPageNavigationEvent.pageName).to.be.equal(pageName)
expect(testPageNavigationEvent.event).to.be.equal(event)
expect(PageNavigationEvent.isPageNavigationEvent(testPageNavigationEvent)).to.be.equal(true)
end
end)
it("should implement tostring and eq", function()
for _, event in pairs(RoactNavigation.Events) do
local pageName = testPage .. tostring(event)
local testPageNavigationEvent = PageNavigationEvent.new(pageName, event)
expect(testPageNavigationEvent:equalTo(PageNavigationEvent.new(pageName, event))).to.be.equal(true)
expect(tostring(testPageNavigationEvent)).to.be.equal(string.format("%-15s - %s",tostring(event), pageName))
end
local testPageNavigationEvent = PageNavigationEvent.new(testPage, willFocusEvent)
expect(testPageNavigationEvent:equalTo(PageNavigationEvent.new(testPage, willFocusEvent))).to.be.equal(true)
expect(testPageNavigationEvent:equalTo(PageNavigationEvent.new(testPage .. "bogus", willFocusEvent))).to.be.equal(false)
expect(testPageNavigationEvent:equalTo(PageNavigationEvent.new(testPage, RoactNavigation.Events.WillBlur))).to.be.equal(false)
end)
end
@@ -0,0 +1,36 @@
local RoactNavigation = require(script.Parent.Parent.Parent)
local TrackNavigationEvents = require(script.Parent.Parent.TrackNavigationEvents)
local PageNavigationEvent = require(script.Parent.Parent.PageNavigationEvent)
return function()
local testPage = "TEST PAGE"
local testPageWillFocus = PageNavigationEvent.new(testPage, RoactNavigation.Events.WillFocus)
local testPageWillBlur = PageNavigationEvent.new(testPage, RoactNavigation.Events.WillBlur)
local trackNavigationEvents = TrackNavigationEvents.new()
it("should implement equalTo function", function()
expect(trackNavigationEvents:equalTo({})).to.be.equal(true)
local navigationEvents = trackNavigationEvents:getNavigationEvents()
table.insert(navigationEvents, testPageWillFocus)
expect(trackNavigationEvents:equalTo({testPageWillFocus})).to.be.equal(true)
expect(trackNavigationEvents:equalTo({})).to.be.equal(false)
table.insert(navigationEvents, testPageWillBlur)
expect(trackNavigationEvents:equalTo({testPageWillFocus, testPageWillBlur})).to.be.equal(true)
table.insert(navigationEvents, testPageWillFocus)
expect(trackNavigationEvents:equalTo({testPageWillFocus, testPageWillBlur})).to.be.equal(false)
expect(trackNavigationEvents:equalTo({testPageWillFocus, testPageWillBlur, testPageWillFocus})).to.be.equal(true)
end)
it("should be empty after reset", function()
trackNavigationEvents:resetNavigationEvents()
local navigationEvents = trackNavigationEvents:getNavigationEvents()
expect(typeof(navigationEvents)).to.be.equal('table')
expect(#navigationEvents).to.be.equal(0)
expect(trackNavigationEvents:equalTo({})).to.be.equal(true)
end)
end
@@ -0,0 +1,47 @@
return function()
local getActiveChildNavigationOptions = require(script.Parent.Parent.getActiveChildNavigationOptions)
it("should return a function", function()
expect(type(getActiveChildNavigationOptions)).to.equal("function")
end)
it("should ask router for current screen options and return them", function()
local testInputScreenOpts = {}
local testScreenOpts = {}
local navigation = {
state = {
routes = {
{ key = "123" }
},
index = 1,
},
router = {}, -- stub
}
function navigation.getChildNavigation(key)
if key == "123" then
return navigation
else
return nil
end
end
local testOutputScreenOpts = nil
function navigation.router.getScreenOptions(activeNav, screenProps)
testOutputScreenOpts = screenProps
if activeNav == navigation then
return testScreenOpts
else
return nil
end
end
expect(getActiveChildNavigationOptions(navigation, testInputScreenOpts))
.to.equal(testScreenOpts)
expect(testOutputScreenOpts).to.equal(testInputScreenOpts)
end)
end
@@ -0,0 +1,25 @@
return function()
local Roact = require(script.Parent.Parent.Parent.Parent.Roact)
local isValidScreenComponent = require(script.Parent.Parent.isValidScreenComponent)
local TestComponent = Roact.Component:extend("TestFoo")
it("should return true for valid element types", function()
-- Function Component is valid
expect(isValidScreenComponent(function() end)).to.equal(true)
-- Stateful Component is valid
expect(isValidScreenComponent(TestComponent)).to.equal(true)
expect(isValidScreenComponent(
{ render = function() return TestComponent end })).to.equal(true)
expect(isValidScreenComponent( -- we do not test if render function returns valid component
{ render = function() end })).to.equal(true)
end)
it("should return false for invalid element types", function()
expect(isValidScreenComponent("foo")).to.equal(false)
expect(isValidScreenComponent(Roact.createElement("Frame"))).to.equal(false)
expect(isValidScreenComponent(5)).to.equal(false)
expect(isValidScreenComponent(Roact.Portal)).to.equal(false)
expect(isValidScreenComponent({ render = "bad" })).to.equal(false)
expect(isValidScreenComponent(
{ notRender = function() return "foo" end })).to.equal(false)
end)
end
@@ -0,0 +1,21 @@
return function()
local lerp = require(script.Parent.Parent.lerp)
it("should return bottom of range for bottom input", function()
expect(lerp(0, 1, 0)).to.equal(0)
expect(lerp(1, 0, 0)).to.equal(1)
expect(lerp(-1, 0, 0)).to.equal(-1)
end)
it("should return middle of range for middle input", function()
expect(lerp(0, 1, 0.5)).to.equal(0.5)
expect(lerp(1, 0, 0.5)).to.equal(0.5)
expect(lerp(-1, 0, 0.5)).to.equal(-0.5)
end)
it("should return top of range for top input", function()
expect(lerp(0, 1, 1)).to.equal(1)
expect(lerp(1, 0, 1)).to.equal(0)
expect(lerp(-1, 0, 1)).to.equal(0)
end)
end
@@ -0,0 +1,202 @@
local StateTable = require(script.Parent.Parent.Parent.StateTable)
local Events = require(script.Parent.Parent.NavigationEvents)
local validate = require(script.Parent.validate)
local WILL_FOCUS_KEY = tostring(Events.WillFocus)
local DID_FOCUS_KEY = tostring(Events.DidFocus)
local WILL_BLUR_KEY = tostring(Events.WillBlur)
local DID_BLUR_KEY = tostring(Events.DidBlur)
local ACTION_KEY = tostring(Events.Action)
local REFOCUS_KEY = tostring(Events.Refocus)
--[[
Provides a StateTable for sending events to user screens on screen state changes.
Args:
name - Name for state table debugging.
emit(type, payload) - Function to emit a single raw event.
disconnectAll() - Function to disconnect all listeners.
States:
Blurred - Screen is not visible, but was at least animated on/off before.
Focusing - Screen is moving to being visible.
Focused - Screen is visible.
Blurring - Screen is moving to being hidden.
Disconnected - Screen is no longer part of the hierarchy and will not propagate events to user.
Accepted Event Types (always sent using Normal Events Pattern):
NavigationEvents.WillFocus
NavigationEvents.DidFocus
NavigationEvents.WillBlur
NavigationEvents.DidBlur
NavigationEvents.Action
NavigationEvents.Refocus - Note that Refocus events do NOT support the pattern. Send them without adornment!
Normal Events Pattern:
[<Event> .. "A"] - Received event while screen is active child and not currently transitioning.
[<Event> .. "T"] - Received event while screen is not active child, but is transitioning.
[<Event> .. "AT"] - Received event while screen is active child and currently transitioning.
[<Event>] - Received event while screen is not active child, and not currently transitioning.
Special Events:
shutdown - Page has been removed from hierarchy, so shut down further event handling.
]]
return function(name, initialState, emit, disconnectAll)
validate(type(name) == "string", "name must be a string")
validate(type(initialState) == "string", "initialState must be a string")
validate(type(emit) == "function", "emitAction must be a function")
validate(type(disconnectAll) == "function", "disconnectAllAction must be a function")
local function doEmit(...)
local eventList = {...}
return function(_, _, payload)
for _, event in ipairs(eventList) do
emit(event, payload)
end
end
end
return StateTable.new("SubscriberEventsTable(" .. name .. ")", initialState, nil, {
Blurred = {
-- If child is active and not transitioning then we jump immediately to Focused state
-- while sending the complete event sequence.
[WILL_FOCUS_KEY .. "A"] = {
nextState = "Focused",
action = doEmit(Events.WillFocus, Events.DidFocus),
},
[ACTION_KEY .. "A"] = {
nextState = "Focused",
action = doEmit(Events.WillFocus, Events.DidFocus),
},
-- If child is active and we're in transition, we are visibly moving to it and
-- a completion event will fire, so move to Focusing state.
[WILL_FOCUS_KEY .. "AT"] = {
nextState = "Focusing",
action = doEmit(Events.WillFocus),
},
[ACTION_KEY .. "AT"] = {
nextState = "Focusing",
action = doEmit(Events.WillFocus),
},
-- Shutdown event can occur after any other event, but only has teeth in blurred state.
-- TODO: Do we need to support this from any state and broadcast all of the events that
-- would lead it to Blurred from the screen's current state?
shutdown = {
nextState = "Disconnected",
action = disconnectAll,
},
-- All Refocus events are simply passed through.
[REFOCUS_KEY] = {
action = doEmit(Events.Refocus),
},
},
Focusing = {
-- Note that DidFocus is only propagated if child is active and we're NOT transitioning, since
-- a child that is still transitioning is not finished yet.
[DID_FOCUS_KEY .. "A"] = {
nextState = "Focused",
action = doEmit(Events.DidFocus),
},
[ACTION_KEY .. "A"] = {
nextState = "Focused",
action = doEmit(Events.DidFocus),
},
-- It's possible to blur while still transitioning on screen due to multiple user taps. Note that
-- in this case, this child would not be active, and navigation state obviously must still be
-- transitioning to the new screen.
[WILL_BLUR_KEY .. "T"] = {
nextState = "Blurring",
action = doEmit(Events.WillBlur),
},
-- All Refocus events are simply passed through.
[REFOCUS_KEY] = {
action = doEmit(Events.Refocus),
},
},
Focused = {
-- Any WillBlur event gets propagated, regardless of active or transitioning states.
[WILL_BLUR_KEY] = {
nextState = "Blurring",
action = doEmit(Events.WillBlur),
},
[WILL_BLUR_KEY .. "T"] = {
nextState = "Blurring",
action = doEmit(Events.WillBlur),
},
[WILL_BLUR_KEY .. "A"] = {
nextState = "Blurring",
action = doEmit(Events.WillBlur),
},
[WILL_BLUR_KEY .. "AT"] = {
nextState = "Blurring",
action = doEmit(Events.WillBlur),
},
-- Action events drive blurring. When the screen is Focused but loses its active mark in
-- navigation state, it becomes blurred.
[ACTION_KEY] = {
nextState = "Blurring",
action = doEmit(Events.WillBlur),
},
[ACTION_KEY .. "T"] = {
nextState = "Blurring",
action = doEmit(Events.WillBlur),
},
-- Any Action event while screen is focused needs to be passed along to the children.
[ACTION_KEY .. "A"] = {
action = doEmit(Events.Action),
},
[ACTION_KEY .. "AT"] = {
action = doEmit(Events.Action),
},
-- All Refocus events are simply passed through.
[REFOCUS_KEY] = {
action = doEmit(Events.Refocus),
},
},
Blurring = {
-- We're done blurring on any action event once we are not focused and no longer transitioning.
[ACTION_KEY] = {
nextState = "Blurred",
action = doEmit(Events.DidBlur),
},
-- If parent is fully blurred, then so are we, and so are our children.
[DID_BLUR_KEY] = {
nextState = "Blurred",
action = doEmit(Events.DidBlur),
},
[DID_BLUR_KEY .. "A"] = {
nextState = "Blurred",
action = doEmit(Events.DidBlur),
},
[DID_BLUR_KEY .. "T"] = {
nextState = "Blurred",
action = doEmit(Events.DidBlur),
},
[DID_BLUR_KEY .. "AT"] = {
nextState = "Blurred",
action = doEmit(Events.DidBlur),
},
-- Action that results in screen being active and there is no transition... We go straight back to Focused state!
[ACTION_KEY .. "A"] = {
nextState = "Focused",
action = doEmit(Events.DidFocus)
},
-- If we become active and nav state is transitioning then user took an action to refocus this screen.
[ACTION_KEY .. "AT"] = {
nextState = "Focusing",
action = doEmit(Events.WillFocus),
},
-- All Refocus events are simply passed through.
[REFOCUS_KEY] = {
action = doEmit(Events.Refocus),
},
},
Disconnected = {},
})
end
@@ -0,0 +1,11 @@
return function(navigation, screenProps)
local state = navigation.state
local router = navigation.router
local getChildNavigation = navigation.getChildNavigation
local activeRoute = state.routes[state.index]
local activeNavigation = getChildNavigation(activeRoute.key)
return router.getScreenOptions(activeNavigation, screenProps)
end
@@ -0,0 +1,44 @@
local Cryo = require(script.Parent.Parent.Parent.Cryo)
return function(props)
local scene = props.scene
local scenes = props.scenes
local index = scene.index
local lastSceneIndexInScenes = #scenes
local isBack = not scenes[lastSceneIndexInScenes].isActive
if isBack then
local currentSceneIndexInScenes = Cryo.List.find(scenes, scene)
local targetSceneIndexInScenes = nil
for i, iScene in ipairs(scenes) do
if iScene.isActive then
targetSceneIndexInScenes = i
break
end
end
local targetSceneIndex = scenes[targetSceneIndexInScenes].index
local lastSceneIndex = scenes[lastSceneIndexInScenes].index
if index ~= targetSceneIndex and currentSceneIndexInScenes == lastSceneIndexInScenes then
return {
first = math.min(targetSceneIndex, index - 1),
last = index + 1,
}
elseif index == targetSceneIndex and currentSceneIndexInScenes == targetSceneIndexInScenes then
return {
first = index - 1,
last = math.max(lastSceneIndex, index + 1)
}
elseif index == targetSceneIndex or currentSceneIndexInScenes > targetSceneIndexInScenes then
return nil
end
end
return {
first = index - 1,
last = index + 1
}
end
@@ -0,0 +1,10 @@
-- We have to do this using type because ElementKind is not exported by Roact
return function (screenComponent)
local componentType = type(screenComponent)
local valid = componentType == "function" or -- Function Component
(componentType == "table" and type(screenComponent.render) == "function") -- Stateful Component
return valid
end
@@ -0,0 +1,6 @@
-- Helper interpolates t with range [0,1] into the range [a,b].
local function lerp(a, b, t)
return a * (1 - t) + b * t
end
return lerp
@@ -0,0 +1,19 @@
--[[
Validate() provides a mechanism to validate input arguments and
internal state for your components. You can call it like this:
function myFunc(arg1, arg2)
validate(arg1 ~= arg2, "arg1 (%s) and arg2 (%s) must be different!",
tostring(arg1), tostring(arg2))
return doSomething(arg1, arg2)
end
The error will be surfaced at the *call site* of your function.
]]
return function(result, ...)
if not result then
error(string.format(...), 3)
end
return result
end
@@ -0,0 +1,60 @@
local Roact = require(script.Parent.Parent.Parent.Roact)
local Cryo = require(script.Parent.Parent.Parent.Cryo)
local NavigationSymbol = require(script.Parent.Parent.NavigationSymbol)
local validate = require(script.Parent.Parent.utils.validate)
local APP_NAVIGATION_CONTEXT = NavigationSymbol("APP_NAVIGATION_CONTEXT")
-- Provider
local NavigationProvider = Roact.Component:extend("NavigationProvider")
function NavigationProvider:init()
local navigation = self.props.navigation
validate(navigation ~= nil, "AppNavigationContext.Provider requires a 'navigation' prop.")
self._context[APP_NAVIGATION_CONTEXT] = { navigation = navigation }
end
function NavigationProvider:render()
return Roact.oneChild(self.props[Roact.Children])
end
-- Consumer
local NavigationConsumer = Roact.Component:extend("NavigationConsumer")
function NavigationConsumer:render()
local renderProp = self.props.render
local context = self._context[APP_NAVIGATION_CONTEXT] or {}
local navigation = self.props.navigation or context.navigation
validate(renderProp ~= nil, "AppNavigationContext.Consumer requires 'render' prop.")
validate(navigation ~= nil, "AppNavigationContext.Consumer requires a navigation prop or context entry.")
return renderProp(navigation)
end
-- Static connector
local function connect(innerComponent)
local componentName = string.format("NavigationConnection(%s)", tostring(innerComponent))
local Connection = Roact.Component:extend(componentName)
function Connection:render()
local props = self.props
return Roact.createElement(NavigationConsumer, {
navigation = props.navigation, -- can be passed directly to wrapper
render = function(navigation)
return Roact.createElement(innerComponent, Cryo.Dictionary.join({
navigation = navigation
}, props)) -- join other props last so someone can manually pass in 'navigation'
end
})
end
return Connection
end
return {
Provider = NavigationProvider,
Consumer = NavigationConsumer,
connect = connect,
}
@@ -0,0 +1,91 @@
local Roact = require(script.Parent.Parent.Parent.Roact)
local AppNavigationContext = require(script.Parent.AppNavigationContext)
local NavigationEvents = require(script.Parent.Parent.NavigationEvents)
local validate = require(script.Parent.Parent.utils.validate)
--[[
NavigationEventsAdapter providers a wrapper component that allows you to subscribe
to the navigation lifecycle events without having to explicitly manage your own
listener subscriptions.
Usage:
function MyComponent:init()
self.willFocus = function()
-- Do tasks that need to happen right before the component will appear on screen.
end
self.didFocus = function()
-- Do tasks that need to happen right after the component appears on screen.
end
end
function MyComponent:render()
-- Note that you must capture the self reference lexically, if you need it.
return Roact.createElement(RoactNavigation.EventsAdapter, {
[RoactNavigation.Events.WillFocus] = self.willFocus,
[RoactNavigation.Events.DidFocus] = self.didFocus,
[RoactNavigation.Events.WillBlur] = self.willBlur,
[RoactNavigation.Events.DidBlur] = self.didBlur,
}, <remainder of component tree> )
end
Remember that focus and blur events may be called more than once in the lifetime of a
component. If you navigate away from a component and then come back later, it will receive
willBlur/didBlur and then willFocus/didFocus events.
Also remember that your event handlers must capture any self reference lexically, if necessary.
]]
local NavigationEventsAdapter = Roact.Component:extend("NavigationEventsAdapter")
function NavigationEventsAdapter:init()
self.subscriptions = {}
end
function NavigationEventsAdapter:_subscribeAll()
local navigation = self.props.navigation
assert(navigation ~= nil, "NavigationEventsAdapter can only be used within the view hierarchy of a navigator.")
for _, symbol in pairs(NavigationEvents) do
self.subscriptions[symbol] = navigation.addListener(symbol, function(...)
-- Retrieve callback from props each time, in case props change.
local callback = self.props[symbol] or nil
if callback then
validate(type(callback) == "function", "Value for event '%s' must be a function callback", tostring(symbol))
callback(...)
end
end)
end
end
function NavigationEventsAdapter:_disconnectAll()
for _, symbol in pairs(NavigationEvents) do
local sub = self.subscriptions[symbol]
if sub then
sub.disconnect()
self.subscriptions[symbol] = nil
end
end
end
function NavigationEventsAdapter:didMount()
self:_subscribeAll()
end
function NavigationEventsAdapter:willUnmount()
self:_disconnectAll()
end
function NavigationEventsAdapter:didUpdate(prevProps)
if self.props.navigation ~= prevProps.navigation then
-- This component might get reused for different state, so we need to hook back up to events
self:_disconnectAll()
self:_subscribeAll()
end
end
function NavigationEventsAdapter:render()
return Roact.createElement("Folder", nil, self.props[Roact.Children])
end
return AppNavigationContext.connect(NavigationEventsAdapter)
@@ -0,0 +1,21 @@
local Roact = require(script.Parent.Parent.Parent.Roact)
local AppNavigationContext = require(script.Parent.AppNavigationContext)
local SceneView = Roact.PureComponent:extend("SceneView")
function SceneView:render()
local screenProps = self.props.screenProps
local component = self.props.component
local navigation = self.props.navigation
return Roact.createElement(AppNavigationContext.Provider, {
navigation = navigation,
}, {
Scene = Roact.createElement(component, {
screenProps = screenProps,
navigation = navigation,
})
})
end
return SceneView
@@ -0,0 +1,201 @@
local Cryo = require(script.Parent.Parent.Parent.Cryo)
local TableUtilities = require(script.Parent.Parent.utils.TableUtilities)
local validate = require(script.Parent.Parent.utils.validate)
local SCENE_KEY_PREFIX = "scene_"
-- Compare two scenes based upon index and view key.
local function compareScenes(a, b)
if a.index == b.index then
-- compare the route keys
local delta = #a.key - #b.key
if delta == 0 then
return a.key < b.key
else
return delta < 0
end
else
-- rank by index first
return a.index < b.index
end
end
local function routesAreShallowEqual(a, b)
if not a or not b then
return a == b
end
if a.key ~= b.key then
return false
end
return TableUtilities.ShallowEqual(a, b)
end
local function scenesAreShallowEqual(a, b)
return
a.key == b.key and
a.index == b.index and
a.isStale == b.isStale and
a.isActive == b.isActive and
routesAreShallowEqual(a, b)
end
return function(scenes, nextState, prevState, descriptors)
-- Always update descriptors. See react-navigation's bug, here:
-- https://github.com/react-navigation/react-navigation/issues/4271
-- TODO: Do we need this? Can we do a real fix?
for _, scene in ipairs(scenes) do
local route = scene.route
if descriptors and descriptors[route.key] then
scene.descriptor = descriptors[route.key]
end
end
-- Bail out early if state is not updated
if prevState == nextState then
return scenes
end
local prevScenes = {}
local freshScenes = {}
local staleScenes = {}
-- previously stale scenes should be marked stale
for _, scene in ipairs(scenes) do
local key = scene.key
if scene.isStale then
staleScenes[key] = scene
end
prevScenes[key] = scene
end
local nextKeys = {} -- fake set!
local nextRoutes = nextState.routes
local nextRoutesLength = #nextRoutes
-- Clip nextRoutes to stop at index because index is top of stack!
if nextRoutesLength > nextState.index then
print("Warning: StackRouter provided invalid state. Index should always be the top route")
nextRoutes = Cryo.List.removeRange(nextRoutes, nextState.index, nextRoutesLength)
end
for index, route in ipairs(nextRoutes) do
local key = SCENE_KEY_PREFIX .. route.key
local descriptor = descriptors and descriptors[route.key] or nil
local scene = {
index = index,
isActive = false,
isStale = false,
key = key,
route = route,
descriptor = descriptor,
}
validate(not nextKeys[key], "navigation.state.routes[%d].key '%s' conflicts with another route!", index, key)
nextKeys[key] = true
if staleScenes[key] then
-- Previously stale scene was added to nextState, so we remove it from
-- the map of stale scenes.
staleScenes[key] = nil
end
freshScenes[key] = scene
end
if prevState then
local prevRoutes = prevState.routes
local prevRoutesLength = #prevRoutes
if prevRoutesLength > prevState.index then
print("StackRouter provided invalid state. Index should always be the top route.")
prevRoutes = Cryo.List.removeRange(prevRoutes, prevState.index, prevRoutesLength)
end
-- Search previous routes and mark any removed scenes as stale
for index, route in ipairs(prevRoutes) do
local key = SCENE_KEY_PREFIX .. route.key
-- Skip any refreshed scenes
if not freshScenes[key] then
local lastScene = nil
for _, scene in ipairs(scenes) do
if scene.route.key == route.key then
lastScene = scene
break
end
end
local descriptor = descriptors[route.key]
if lastScene then
descriptor = lastScene.descriptor
end
if descriptor then
staleScenes[key] = {
index = index,
isActive = false,
isStale = true,
key = key,
route = route,
descriptor = descriptor,
}
end
end
end
end
local nextScenes = {}
local function mergeScene(nextScene)
local key = nextScene.key
local prevScene = prevScenes[key] or nil
if prevScene and scenesAreShallowEqual(prevScene, nextScene) then
-- reuse prevScene to avoid re-render
table.insert(nextScenes, prevScene)
else
table.insert(nextScenes, nextScene)
end
end
for _, scene in pairs(staleScenes) do
mergeScene(scene)
end
for _, scene in pairs(freshScenes) do
mergeScene(scene)
end
table.sort(nextScenes, compareScenes)
local activeScenesCount = 0
for index, scene in ipairs(nextScenes) do
local isActive = not scene.isStale and scene.index == nextState.index
if isActive ~= scene.isActive then
nextScenes[index] = Cryo.Dictionary.join(scene, {
isActive = isActive,
})
end
if isActive then
activeScenesCount = activeScenesCount + 1
end
end
validate(activeScenesCount == 1, "There should only be one active scene, not %d", activeScenesCount)
-- Conditionally return nextScenes based upon shallow comparison, for performance
if #nextScenes ~= #scenes then
return nextScenes
end
for index, scene in ipairs(nextScenes) do
if not scenesAreShallowEqual(scenes[index], scene) then
return nextScenes
end
end
-- Scenes have not changed
return scenes
end
@@ -0,0 +1,36 @@
local Cryo = require(script.Parent.Parent.Parent.Cryo)
local Roact = require(script.Parent.Parent.Parent.Roact)
local ScreenGuiWrapper = Roact.PureComponent:extend("ScreenGuiWrapper")
ScreenGuiWrapper.defaultProps = {
DisplayOrder = 0,
OnTopOfCoreBlur = false,
visible = true,
}
function ScreenGuiWrapper:render()
local props = self.props
local component = props.component
local visible = props.visible
local displayOrder = props.DisplayOrder
local onTopOfCoreBlur = props.OnTopOfCoreBlur
local filteredProps = Cryo.Dictionary.join(props, {
component = Cryo.None,
DisplayOrder = Cryo.None,
OnTopOfCoreBlur = Cryo.None,
-- visible prop is passed down for convenience of inner component.
})
return Roact.createElement("ScreenGui", {
Enabled = visible,
ZIndexBehavior = Enum.ZIndexBehavior.Sibling,
DisplayOrder = displayOrder,
OnTopOfCoreBlur = onTopOfCoreBlur,
}, {
InnerComponent = Roact.createElement(component, filteredProps)
})
end
return ScreenGuiWrapper
@@ -0,0 +1,43 @@
local NavigationSymbol = require(script.Parent.Parent.Parent.NavigationSymbol)
local DEFAULT_SYMBOL = NavigationSymbol("DEFAULT")
local MODAL_SYMBOL = NavigationSymbol("MODAL")
local OVERLAY_SYMBOL = NavigationSymbol("OVERLAY")
--[[
StackPresentationStyle is used with stack navigators/views to determine
the behavior of a given screen when it is pushed/popped from the stack, as
well as the visual effects/transitions applied while the view is on screen.
]]
return {
--[[
The Default presentation style draws stack cards so that they will
slide in and out from the right side of the screen one at a time. No
special visual effects are applied, and cards always fill the entire space
available. Your screen content is rendered over an opaque background color
by default, but you have the option to draw the cards with a semi- or fully
transparent background via navigationOptions. Cards always prevent
tap-through of the content underneath them (in case your navigation
container is transparent).
]]
Default = DEFAULT_SYMBOL,
--[[
The Modal presentation style causes screens to animate up/down from the
bottom of the navigation container and visually stack on top of each
other. Cards are opaque by default, but you may set navigationOptions
to make them semi- or fully transparent so that you can see the underlying
cards. Modal cards always prevent tap-through of any underlying UI, including
other cards in the same stack.
]]
Modal = MODAL_SYMBOL,
--[[
The Overlay presentation style causes screens to pop in (later they will fade in)
on top of the underlying screens. Like modals, they visually stack on top of each
other. Cards are opaque by default, but you may set navigationOptions to make
them semi- or fully transparent. Overlay cards always prevent tap-through of any
underlying UI, including other cards in the same stack.
]]
Overlay = OVERLAY_SYMBOL
}
@@ -0,0 +1,134 @@
local Cryo = require(script.Parent.Parent.Parent.Parent.Cryo)
local Roact = require(script.Parent.Parent.Parent.Parent.Roact)
local NavigationActions = require(script.Parent.Parent.Parent.NavigationActions)
local StackViewLayout = require(script.Parent.StackViewLayout)
local Transitioner = require(script.Parent.Parent.Transitioner)
local StackViewTransitionConfigs = require(script.Parent.StackViewTransitionConfigs)
local StackPresentationStyle = require(script.Parent.StackPresentationStyle)
local defaultNavigationConfig = {
mode = StackPresentationStyle.Default,
}
local StackView = Roact.Component:extend("StackView")
function StackView:init()
self._doRender = function(...)
return self:_render(...)
end
self._doConfigureTransition = function(...)
return self:_configureTransition(...)
end
self._doOnTransitionStart = function(...)
return self:_onTransitionStart(...)
end
self._doOnTransitionEnd = function(...)
return self:_onTransitionEnd(...)
end
self._doOnTransitionStep = function(...)
return self:_onTransitionStep(...)
end
end
function StackView:render()
local screenProps = self.props.screenProps
local navigation = self.props.navigation
local descriptors = self.props.descriptors
-- Transitioner handles setting up the animation motors and making that data
-- available to the lower layer.
return Roact.createElement(Transitioner, {
render = self._doRender,
configureTransition = self._doConfigureTransition,
screenProps = screenProps,
navigation = navigation,
descriptors = descriptors,
onTransitionStart = self._doOnTransitionStart,
onTransitionEnd = self._doOnTransitionEnd,
onTransitionStep = self._doOnTransitionStep,
})
end
function StackView:didMount()
local navigation = self.props.navigation
if navigation.state.isTransitioning then
navigation.dispatch(NavigationActions.completeTransition({
key = navigation.state.key,
}))
end
end
function StackView:_render(transition, lastTransition)
local screenProps = self.props.screenProps
local navigationConfig = Cryo.Dictionary.join(defaultNavigationConfig, self.props.navigationConfig)
local descriptors = self.props.descriptors
return Roact.createElement(StackViewLayout, Cryo.Dictionary.join(navigationConfig, {
screenProps = screenProps,
descriptors = descriptors,
transitionProps = transition,
lastTransitionProps = lastTransition,
}))
end
function StackView:_configureTransition(transition, lastTransition)
return StackViewTransitionConfigs.getTransitionConfig(
self.props.navigationConfig.transitionConfig,
transition,
lastTransition,
self.props.navigationConfig.mode
).transitionSpec
end
function StackView:_onTransitionStart(transition, lastTransition)
local onTransitionStart = self.props.onTransitionStart
or self.props.navigationConfig.onTransitionStart
-- Only propagate transition changes to caller for transitions where the actual
-- index has changed. Transitioner sends updates for _all_ transitions, including
-- those to the same screen that result from animation completion events.
if onTransitionStart and transition.index ~= lastTransition.index then
onTransitionStart(transition.navigation, lastTransition.navigation)
end
end
function StackView:_onTransitionEnd(transition, lastTransition)
local navigationConfig = self.props.navigationConfig
local navigation = self.props.navigation
local onTransitionEnd = self.props.onTransitionEnd or navigationConfig.onTransitionEnd
local transitionDestKey = transition.scene.route.key
local isCurrentKey = navigation.state.routes[navigation.state.index].key == transitionDestKey
if transition.navigation.state.isTransitioning and isCurrentKey then
navigation.dispatch(NavigationActions.completeTransition({
key = navigation.state.key,
toChildKey = transitionDestKey,
}))
end
-- Only propagate transition changes to caller for transitions where the actual
-- index has changed. Transitioner sends updates for _all_ transitions, including
-- those to the same screen that result from animation completion events.
if onTransitionEnd and transition.index ~= lastTransition.index then
onTransitionEnd(transition.navigation, lastTransition.navigation)
end
end
function StackView:_onTransitionStep(transition, lastTransition, value)
local onTransitionStep = self.props.onTransitionStep
or self.props.navigationConfig.onTransitionStep
-- Only propagate transition changes to caller for transitions where the actual
-- index has changed. Transitioner sends updates for _all_ transitions, including
-- those to the same screen that result from animation completion events.
if onTransitionStep and transition.index ~= lastTransition.index then
onTransitionStep(transition.navigation, lastTransition.navigation, value)
end
end
return StackView
@@ -0,0 +1,116 @@
local Roact = require(script.Parent.Parent.Parent.Parent.Roact)
local validate = require(script.Parent.Parent.Parent.utils.validate)
--[[
Render a scene as a card for use in a StackView. This component is
responsible for correctly positioning the scene content in relation
to the other scenes. The content will be rendered inside a Frame
whose position and size are controlled by the transition logic. The
frame may either be transparent or a solid color, depending upon props.
Any additional visual effects must be supplied by the container or the
child element created by renderScene().
Props:
renderScene(scene) -- Render prop to draw the scene inside the card.
initialPosition -- Starting position for the card. (Animated by Otter from there).
positionStep -- Stepper function from StackViewInterpolator.
position -- Otter motor for the position of the card.
scene -- Scene that the card is to render.
forceHidden -- Forcibly disable card rendering (e.g. animated off-screen).
transparent -- Card allows underlying content to show through (default: false).
cardColor3 -- Color of the card background if it's not transparent (default: white).
]]
local StackViewCard = Roact.Component:extend("StackViewCard")
StackViewCard.defaultProps = {
transparent = false,
cardColor3 = Color3.new(1, 1, 1),
}
function StackViewCard:init()
local currentNavIndex = self.props.navigation.state.index
self._isMounted = false
self._positionLastValue = currentNavIndex
local selfRef = Roact.createRef()
self._getRef = function()
return self.props[Roact.Ref] or selfRef
end
end
function StackViewCard:render()
local forceHidden = self.props.forceHidden
local cardColor3 = self.props.cardColor3
local transparent = self.props.transparent
local initialPosition = self.props.initialPosition
local renderScene = self.props.renderScene
local scene = self.props.scene
validate(type(renderScene) == "function", "renderScene must be a function")
return Roact.createElement("Frame", {
Position = initialPosition,
Size = UDim2.new(1, 0, 1, 0),
BackgroundColor3 = cardColor3,
BackgroundTransparency = transparent and 1 or nil,
BorderSizePixel = 0,
ClipsDescendants = true,
Visible = not forceHidden,
[Roact.Ref] = self:_getRef(),
}, {
Content = renderScene(scene),
})
end
function StackViewCard:didMount()
self._isMounted = true
local position = self.props.position
self._positionDisconnector = position:onStep(function(...)
self:_onPositionStep(...)
end)
end
function StackViewCard:willUnmount()
self._isMounted = false
if self._positionDisconnector then
self._positionDisconnector()
self._positionDisconnector = nil
end
end
function StackViewCard:didUpdate(oldProps)
local position = self.props.position
local positionStep = self.props.positionStep
if position ~= oldProps.position then
self._positionDisconnector()
self._positionDisconnector = position:onStep(function(...)
self:_onPositionStep(...)
end)
end
if positionStep ~= oldProps.positionStep then
-- The motor won't fire just because stepper function has changed. We have to
-- update the position to match new requirements based upon last motor value.
self:_onPositionStep(self._positionLastValue)
end
end
function StackViewCard:_onPositionStep(value)
if not self._isMounted then
return
end
local positionStep = self.props.positionStep
if positionStep then
positionStep(self:_getRef(), value)
end
self._positionLastValue = value
end
return StackViewCard
@@ -0,0 +1,216 @@
--[[
Provides builders to create functions that interpolate the current Otter motor
position into the correct translation for stack cards based upon their associated
scene.
Interpolator builders expect the following props as input:
{
initialPositionValue = <value of position motor that transition began from>,
scene = <scene for the particular card being animated>,
layout = {
initWidth = <expected width of card>,
initHeight = <expected height of card>,
isMeasured = <boolean: true if initWidth+Height have been measured, else false>,
}
}
Each builder returns a props table to be merged onto your other StackViewCard props, ex:
{
positionStep = <stepper function, or nil if not needed>,
initialPosition = <starting position UDim2 for card based upon current active scene index>,
forceHidden = true, -- May disable card visibility if it's outside interpolating range.
}
The props table may contain other changes, depending on the requirements of the animation.
]]
local getSceneIndicesForInterpolationInputRange = require(
script.Parent.Parent.Parent.utils.getSceneIndicesForInterpolationInputRange)
local lerp = require(script.Parent.Parent.Parent.utils.lerp)
-- Render initial style when layout hasn't been measured yet.
local function forInitial(props)
local initialPositionValue = props.initialPositionValue
local scene = props.scene
local forceHidden = initialPositionValue ~= scene.index
local translate = forceHidden and 1000000 or 0
return {
forceHidden = forceHidden,
initialPosition = UDim2.new(0, translate, 0, translate),
positionStep = nil,
}
end
-- Slide-in from right style (e.g. navigation stack view).
local function forHorizontal(props)
local initialPositionValue = props.initialPositionValue
local layout = props.layout
local scene = props.scene
if not layout.isMeasured then
return forInitial(props)
end
local interpolate = getSceneIndicesForInterpolationInputRange(props)
-- getSceneIndices* returns nil if card is not visible and need not be
-- considered for the animation until state changes.
if not interpolate then
return {
forceHidden = true,
initialPosition = UDim2.new(0, 100000, 0, 100000),
positionStep = nil,
}
end
local first = interpolate.first
local last = interpolate.last
local index = scene.index
local width = layout.initWidth
local function calculate(positionValue)
-- 3 range LERP
if positionValue < first then
return width
elseif positionValue < index then
return lerp(width, 0, (positionValue - first) / (index - first))
elseif positionValue == index then
return 0
elseif positionValue < last then
return lerp(0, -width, (positionValue - index) / (last - index))
else
return -width
end
end
local function stepper(cardRef, positionValue)
local cardInstance = cardRef.current
if not cardInstance then
return
end
local oldPosition = cardInstance.Position
cardInstance.Position = UDim2.new(
oldPosition.X.Scale,
calculate(positionValue),
oldPosition.Y.Scale,
oldPosition.Y.Offset
)
end
local initialPosition = UDim2.new(0, calculate(initialPositionValue), 0, 0)
return {
initialPosition = initialPosition,
positionStep = stepper,
}
end
-- Slide-in from bottom style (e.g. modals).
local function forVertical(props)
local initialPositionValue = props.initialPositionValue
local layout = props.layout
local scene = props.scene
if not layout.isMeasured then
return forInitial(props)
end
local interpolate = getSceneIndicesForInterpolationInputRange(props)
if not interpolate then
return {
forceHidden = true,
initialPosition = UDim2.new(0, 100000, 0, 100000),
positionStep = nil,
}
end
local first = interpolate.first
local index = scene.index
local height = layout.initHeight
local function calculate(positionValue)
-- 2 range LERP
if positionValue < first then
return height
elseif positionValue < index then
return lerp(height, 0, (positionValue - first) / (index - first))
else
return 0
end
end
local function stepper(cardRef, positionValue)
local cardInstance = cardRef.current
if not cardInstance then
return
end
local oldPosition = cardInstance.Position
cardInstance.Position = UDim2.new(
oldPosition.X.Scale,
oldPosition.X.Offset,
oldPosition.Y.Scale,
calculate(positionValue)
)
end
local initialPosition = UDim2.new(0, 0, 0, calculate(initialPositionValue))
return {
initialPosition = initialPosition,
positionStep = stepper,
}
end
-- Fade in place animation (e.g. popovers and toasts). Note that since we don't currently have
-- group transparency, this 'animation' just pops the views in for now.
local function forFade(props)
local initialPositionValue = props.initialPositionValue
local layout = props.layout
local scene = props.scene
if not layout.isMeasured then
return forInitial(props)
end
local interpolate = getSceneIndicesForInterpolationInputRange(props)
if not interpolate then
return {
forceHidden = true,
initialPosition = UDim2.new(0, 100000, 0, 100000),
positionStep = nil,
}
end
local index = scene.index
local function calculate(positionValue)
return positionValue >= index - 0.5
end
local function stepper(cardRef, positionValue)
local cardInstance = cardRef.current
if not cardInstance then
return
end
cardInstance.Visible = calculate(positionValue)
end
return {
forceHidden = not calculate(initialPositionValue),
initialPosition = UDim2.new(0, 0, 0, 0),
positionStep = stepper,
}
end
return {
forHorizontal = forHorizontal,
forVertical = forVertical,
forFade = forFade,
}
@@ -0,0 +1,265 @@
local Cryo = require(script.Parent.Parent.Parent.Parent.Cryo)
local Roact = require(script.Parent.Parent.Parent.Parent.Roact)
local StackPresentationStyle = require(script.Parent.StackPresentationStyle)
local StackViewTransitionConfigs = require(script.Parent.StackViewTransitionConfigs)
local StackViewOverlayFrame = require(script.Parent.StackViewOverlayFrame)
local StackViewCard = require(script.Parent.StackViewCard)
local SceneView = require(script.Parent.Parent.SceneView)
local defaultScreenOptions = {
absorbInput = true,
overlayEnabled = false,
overlayColor3 = Color3.new(0, 0, 0),
overlayTransparency = 0.7,
-- cardColor3 default is provided by StackViewCard
renderOverlay = function(navigationOptions, initialTransitionValue, transitionChangedSignal)
-- NOTE: renderOverlay will not be called if sceneOptions.overlayEnabled evaluates false
return Roact.createElement(StackViewOverlayFrame, {
navigationOptions = navigationOptions,
initialTransitionValue = initialTransitionValue,
transitionChangedSignal = transitionChangedSignal,
})
end,
}
local function calculateTransitionValue(index, position)
return math.max(math.min(1 + position - index, 1), 0)
end
local StackViewLayout = Roact.Component:extend("StackViewLayout")
function StackViewLayout:init()
local startingIndex = self.props.transitionProps.navigation.state.index
self._isMounted = false
self._positionLastValue = startingIndex
self._renderScene = function(scene)
return self:_renderInnerScene(scene)
end
self._subscribeToOverlayUpdates = function(callback)
local position = self.props.transitionProps.position
local index = self.props.transitionProps.scene.index
return position:onStep(function(value)
callback(calculateTransitionValue(index, value))
end)
end
end
function StackViewLayout:_renderCard(scene, navigationOptions)
local transitionProps = self.props.transitionProps -- Core animation info from Transitioner.
local lastTransitionProps = self.props.lastTransitionProps -- Previous transition info.
local transitionConfig = self.state.transitionConfig -- State based info from scene config.
local cardColor3 = navigationOptions.cardColor3
local overlayEnabled = navigationOptions.overlayEnabled
local initialPositionValue = transitionProps.scene.index
if lastTransitionProps then
initialPositionValue = lastTransitionProps.scene.index
end
local cardInterpolationProps = {}
local screenInterpolator = transitionConfig.screenInterpolator
if screenInterpolator then
cardInterpolationProps = screenInterpolator(
Cryo.Dictionary.join(transitionProps, {
initialPositionValue = initialPositionValue,
scene = scene,
})
)
end
-- Merge down the various prop packages to be applied to StackViewCard.
return Roact.createElement(StackViewCard, Cryo.Dictionary.join(
transitionProps, cardInterpolationProps, {
key = "card_" .. tostring(scene.key),
scene = scene,
renderScene = self._renderScene,
transparent = overlayEnabled,
cardColor3 = cardColor3,
})
)
end
function StackViewLayout:_renderInnerScene(scene)
local navigation = scene.descriptor.navigation
local sceneComponent = scene.descriptor.getComponent()
local screenProps = self.props.screenProps
return Roact.createElement(SceneView, {
screenProps = screenProps,
navigation = navigation,
component = sceneComponent,
})
end
function StackViewLayout:render()
local transitionProps = self.props.transitionProps
local topMostOpaqueSceneIndex = self.state.topMostOpaqueSceneIndex
local scenes = transitionProps.scenes
local renderedScenes = Cryo.List.map(scenes, function(scene)
-- The card is obscured if:
-- It's not the active card (e.g. we're transitioning TO it).
-- It's hidden underneath an opaque card that is NOT currently transitioning.
-- It's completely off-screen.
local cardObscured = scene.index < topMostOpaqueSceneIndex and not scene.isActive
local screenOptions = Cryo.Dictionary.join(defaultScreenOptions, scene.descriptor.options or {})
local overlayEnabled = screenOptions.overlayEnabled
local absorbInput = screenOptions.absorbInput
local renderOverlay = screenOptions.renderOverlay
local stationaryContent = nil
if overlayEnabled then
stationaryContent = Roact.createElement("Frame", {
Size = UDim2.new(1, 0, 1, 0),
BackgroundTransparency = 1,
ClipsDescendants = true,
BorderSizePixel = 0,
ZIndex = 1,
}, {
Overlay = renderOverlay(
screenOptions,
calculateTransitionValue(scene.index, self._positionLastValue),
self._subscribeToOverlayUpdates)
})
end
-- Wrapper frame holds default/custom card background and the card content.
-- It MUST be a Frame when absorbInput=false because of legacy behavior on desktop
-- for GuiObject.Active=false blocking mouse clicks from falling through.
-- (Active=false DOES work on mobile, but not desktop).
-- When absorbInput=true, we add a TextButton behind the frame that will catch
-- mouse clicks
local absorbInputElement = nil
if not cardObscured and absorbInput then
absorbInputElement = Roact.createElement("TextButton", {
Active = true,
AutoButtonColor = false,
BackgroundTransparency = 1,
BorderSizePixel = 0,
ClipsDescendants = true,
Size = UDim2.new(1, 0, 1, 0),
Text = " ",
ZIndex = 2 * scene.index - 1,
})
end
return Roact.createFragment({
AbsorbInput = absorbInputElement,
-- use scene index for key, it makes testing with Rhodium easier
[tostring(scene.index)] = Roact.createElement("Frame", {
Size = UDim2.new(1, 0, 1, 0),
BackgroundTransparency = 1,
BorderSizePixel = 0,
ClipsDescendants = true,
ZIndex = 2 * scene.index,
Visible = not cardObscured,
}, {
StationaryContent = stationaryContent,
DynamicContent = Roact.createElement("Frame", {
Size = UDim2.new(1, 0, 1, 0),
BackgroundTransparency = 1,
ClipsDescendants = true,
BorderSizePixel = 0,
ZIndex = 2,
}, {
-- Cards need to have unique keys so that instances of the same components are not
-- reused for different scenes. (Could lead to unanticipated lifecycle problems).
["card_" .. scene.key] = self:_renderCard(scene, screenOptions),
})
}),
})
end)
return Roact.createElement("Frame", {
Size = UDim2.new(1, 0, 1, 0),
BackgroundTransparency = 1,
ClipsDescendants = true,
BorderSizePixel = 0,
}, renderedScenes)
end
function StackViewLayout.getDerivedStateFromProps(nextProps, lastState)
local transitionProps = nextProps.transitionProps
local scenes = transitionProps.scenes
local state = transitionProps.navigation.state
local isTransitioning = state.isTransitioning
local topMostIndex = #scenes
local isOverlayMode = nextProps.mode == StackPresentationStyle.Modal or
nextProps.mode == StackPresentationStyle.Overlay
-- Find the last opaque scene in a modal stack so that we can optimize rendering.
local topMostOpaqueSceneIndex = 0
if isOverlayMode then
for idx = topMostIndex, 1, -1 do
local scene = scenes[idx]
local navigationOptions = Cryo.Dictionary.join(defaultScreenOptions, scene.descriptor.options or {})
-- Card covers other pages if it's not an overlay and it's not the top-most index while transitioning.
if not navigationOptions.overlayEnabled and not (isTransitioning and idx == topMostIndex) then
topMostOpaqueSceneIndex = idx
break
end
end
else
for idx = topMostIndex, 1, -1 do
if not (isTransitioning and idx == topMostIndex) then
topMostOpaqueSceneIndex = idx
break
end
end
end
return {
topMostOpaqueSceneIndex = topMostOpaqueSceneIndex,
transitionConfig = StackViewTransitionConfigs.getTransitionConfig(
nextProps.transitionConfig,
nextProps.transitionProps,
nextProps.lastTransitionProps,
nextProps.mode),
}
end
function StackViewLayout:didMount()
self._isMounted = true
self._positionDisconnector = self.props.transitionProps.position:onStep(function(...)
self:_onPositionStep(...)
end)
end
function StackViewLayout:willUnmount()
self._isMounted = false
if self._positionDisconnector then
self._positionDisconnector()
self._positionDisconnector = nil
end
end
function StackViewLayout:didUpdate(oldProps)
local position = self.props.transitionProps.position
if position ~= oldProps.transitionProps.position then
self._positionDisconnector()
self._positionDisconnector = position:onStep(function(...)
self:_onPositionStep(...)
end)
end
end
function StackViewLayout:_onPositionStep(value)
if self._isMounted then
self._positionLastValue = value
end
end
return StackViewLayout
@@ -0,0 +1,71 @@
local Roact = require(script.Parent.Parent.Parent.Parent.Roact)
local lerp = require(script.Parent.Parent.Parent.utils.lerp)
local StackViewOverlayFrame = Roact.Component:extend("StackViewOverlayFrame")
function StackViewOverlayFrame:init()
self._signalDisconnect = nil
local selfRef = Roact.createRef()
self._getRef = function()
return self.props[Roact.Ref] or selfRef
end
end
function StackViewOverlayFrame:render()
local navigationOptions = self.props.navigationOptions
local initialTransitionValue = self.props.initialTransitionValue
local overlayTransparency = lerp(1, navigationOptions.overlayTransparency, initialTransitionValue)
return Roact.createElement("Frame", {
Size = UDim2.new(1, 0, 1, 0),
BackgroundColor3 = navigationOptions.overlayColor3,
BackgroundTransparency = overlayTransparency,
BorderSizePixel = 0,
[Roact.Ref] = self:_getRef(),
})
end
function StackViewOverlayFrame:didUpdate(oldProps)
local transitionChangedSignal = self.props.transitionChangedSignal
if transitionChangedSignal ~= oldProps.transitionChangedSignal then
if self._signalDisconnect then
self._signalDisconnect()
end
self._signalDisconnect = transitionChangedSignal(function(...)
self:_transitionChanged(...)
end)
end
end
function StackViewOverlayFrame:didMount()
self._isMounted = true
self._signalDisconnect = self.props.transitionChangedSignal(function(...)
self:_transitionChanged(...)
end)
end
function StackViewOverlayFrame:willUnmount()
self._isMounted = false
if self._signalDisconnect then
self._signalDisconnect()
end
end
function StackViewOverlayFrame:_transitionChanged(value)
if not self._isMounted then
return
end
local myRef = self:_getRef()
if myRef.current then
local navigationOptions = self.props.navigationOptions
local overlayTransparency = lerp(1, navigationOptions.overlayTransparency, value)
myRef.current.BackgroundTransparency = overlayTransparency
end
end
return StackViewOverlayFrame
@@ -0,0 +1,53 @@
local Cryo = require(script.Parent.Parent.Parent.Parent.Cryo)
local StackViewInterpolator = require(script.Parent.StackViewInterpolator)
local StackPresentationStyle = require(script.Parent.StackPresentationStyle)
local DefaultTransitionSpec = {
frequency = 3, -- Hz
dampingRatio = 1,
}
local SlideFromRight = {
transitionSpec = DefaultTransitionSpec,
screenInterpolator = StackViewInterpolator.forHorizontal,
}
local ModalSlideFromBottom = {
transitionSpec = DefaultTransitionSpec,
screenInterpolator = StackViewInterpolator.forVertical,
}
local FadeInPlace = {
transitionSpec = DefaultTransitionSpec,
screenInterpolator = StackViewInterpolator.forFade,
}
local function getDefaultTransitionConfig(transitionProps, prevTransitionProps, presentationStyle)
if presentationStyle == StackPresentationStyle.Modal then
return ModalSlideFromBottom
elseif presentationStyle == StackPresentationStyle.Overlay then
return FadeInPlace
else
return SlideFromRight
end
end
local function getTransitionConfig(transitionConfigurer, transitionProps, prevTransitionProps, presentationStyle)
local defaultConfig = getDefaultTransitionConfig(transitionProps, prevTransitionProps, presentationStyle)
if transitionConfigurer then
return Cryo.Dictionary.join(
defaultConfig,
transitionConfigurer(transitionProps, prevTransitionProps, presentationStyle)
)
end
return defaultConfig
end
return {
getDefaultTransitionConfig = getDefaultTransitionConfig,
getTransitionConfig = getTransitionConfig,
SlideFromRight = SlideFromRight,
ModalSlideFromBottom = ModalSlideFromBottom,
FadeInPlace = FadeInPlace,
}
@@ -0,0 +1,17 @@
return function()
local StackPresentationStyle = require(script.Parent.Parent.StackPresentationStyle)
describe("StackPresentationStyle token tests", function()
it("should return same object for each token for multiple calls", function()
expect(StackPresentationStyle.Default).to.equal(StackPresentationStyle.Default)
expect(StackPresentationStyle.Modal).to.equal(StackPresentationStyle.Modal)
expect(StackPresentationStyle.Overlay).to.equal(StackPresentationStyle.Overlay)
end)
it("should return matching string names for symbols", function()
expect(tostring(StackPresentationStyle.Default)).to.equal("DEFAULT")
expect(tostring(StackPresentationStyle.Modal)).to.equal("MODAL")
expect(tostring(StackPresentationStyle.Overlay)).to.equal("OVERLAY")
end)
end)
end
@@ -0,0 +1,8 @@
return function()
-- local Roact = require(script.Parent.Parent.Parent.Parent.Roact)
-- local StackView = require(script.Parent.Parent.StackView)
itSKIP("should have its tests implemented", function()
end)
end
@@ -0,0 +1,36 @@
return function()
local Otter = require(script.Parent.Parent.Parent.Parent.Parent.Otter)
local Roact = require(script.Parent.Parent.Parent.Parent.Parent.Roact)
local StackViewCard = require(script.Parent.Parent.StackViewCard)
it("should mount its renderProp and pass it scene", function()
local didRender = false
local testScene = {
isActive = true,
index = 1,
}
local renderedScene = nil
local element = Roact.createElement(StackViewCard, {
renderScene = function(theScene)
renderedScene = theScene
return Roact.createElement(function()
didRender = true -- verifies component is attached to tree
end)
end,
scene = testScene,
position = Otter.createSingleMotor(1),
navigation = {
state = {
index = 1,
}
}
})
local instance = Roact.mount(element)
Roact.unmount(instance)
expect(renderedScene).to.equal(testScene)
expect(didRender).to.equal(true)
end)
end
@@ -0,0 +1,7 @@
return function()
local StackViewInterpolator = require(script.Parent.Parent.StackViewInterpolator)
itSKIP("should have its tests implemented", function()
end)
end
@@ -0,0 +1,8 @@
return function()
-- local Roact = require(script.Parent.Parent.Parent.Parent.Roact)
-- local StackViewLayout = require(script.Parent.Parent.StackViewLayout)
itSKIP("should have its tests implemented", function()
end)
end
@@ -0,0 +1,5 @@
return function()
itSKIP("should have its tests implemented", function()
end)
end
@@ -0,0 +1,68 @@
local Cryo = require(script.Parent.Parent.Parent.Cryo)
local Roact = require(script.Parent.Parent.Parent.Roact)
local SceneView = require(script.Parent.SceneView)
local defaultNavigationConfig = {
keepVisitedScreensMounted = false,
}
local SwitchView = Roact.Component:extend("SwitchView")
function SwitchView.getDerivedStateFromProps(nextProps, prevState)
local navState = nextProps.navigation.state
local activeKey = navState.routes[navState.index].key
local descriptors = nextProps.descriptors
local navigationConfig = Cryo.Dictionary.join(defaultNavigationConfig, nextProps.navigationConfig or {})
local keepVisitedScreensMounted = navigationConfig.keepVisitedScreensMounted
local visitedScreenKeys = {
[activeKey] = true
}
if keepVisitedScreensMounted then
-- prune visited screen keys if they are not included in incoming descriptors
for prevKey in pairs(prevState.visitedScreenKeys or {}) do
if descriptors[prevKey] ~= nil then
visitedScreenKeys[prevKey] = true
end
end
end
return {
visitedScreenKeys = visitedScreenKeys,
}
end
function SwitchView:render()
local navState = self.props.navigation.state
local screenProps = self.props.screenProps
local descriptors = self.props.descriptors
local visitedScreenKeys = self.state.visitedScreenKeys
local activeKey = navState.routes[navState.index].key
local screenElements = {}
for key, descriptor in pairs(descriptors) do
local isActiveKey = (key == activeKey)
if visitedScreenKeys[key] == true then
screenElements["card_" .. key] = Roact.createElement("Frame", {
Size = UDim2.new(1, 0, 1, 0),
BackgroundTransparency = 1,
ClipsDescendants = true,
BorderSizePixel = 0,
Visible = isActiveKey,
}, {
Content = Roact.createElement(SceneView, {
component = descriptor.getComponent(),
navigation = descriptor.navigation,
screenProps = screenProps,
})
})
end
end
return Roact.createElement("Folder", nil, screenElements)
end
return SwitchView
@@ -0,0 +1,310 @@
local Cryo = require(script.Parent.Parent.Parent.Cryo)
local Roact = require(script.Parent.Parent.Parent.Roact)
local Otter = require(script.Parent.Parent.Parent.Otter)
local ScenesReducer = require(script.Parent.ScenesReducer)
local validate = require(script.Parent.Parent.utils.validate)
local DEFAULT_TRANSITION_SPEC = {
frequency = 4 -- Hz
}
local function buildTransitionProps(props, state)
local navigation = props.navigation
local options = props.options
local layout = state.layout
local position = state.position
local scenes = state.scenes
local activeScene
for _, x in ipairs(scenes) do
if x.isActive then
activeScene = x
break
end
end
validate(activeScene, "Could not find active scene")
return {
layout = layout,
navigation = navigation,
position = position,
scenes = scenes,
scene = activeScene,
options = options,
index = activeScene.index,
}
end
local function filterStale(scenes)
local filtered = Cryo.List.filter(scenes, function(scene)
return not scene.isStale
end)
if #filtered == #scenes then
return scenes
else
return filtered
end
end
local Transitioner = Roact.Component:extend("Transitioner")
function Transitioner:init()
local navigationState = self.props.navigation.state
local descriptors = self.props.descriptors
self.state = {
-- Layout is passed to StackViewLayout in order to allow it to
-- sync animations.
layout = {
initWidth = 0,
initHeight = 0,
isMeasured = false,
},
position = Otter.createSingleMotor(navigationState.index),
scenes = ScenesReducer({}, navigationState, nil, descriptors),
}
self._doOnAbsoluteSizeChanged = function(...)
return self:_onAbsoluteSizeChanged(...)
end
self._positionLastValue = navigationState.index
self._prevTransitionProps = nil
self._transitionProps = buildTransitionProps(self.props, self.state)
self._isMounted = false
self._isTransitionRunning = false
self._transitionQueue = {}
self._completeSignalDisconnector = self.state.position:onComplete(function()
-- This spawn is required because of this Otter bug: https://github.com/Roblox/otter/issues/26
-- Otter.SingleMotor's step function calls onComplete before it calls stop(). This leaves their
-- __running=true, and the setGoal() in our _onTransitionEnd() does nothing. So the whole queue
-- handling just stops cold, and the transition never actually happens!
spawn(function()
if self._isMounted then
self:_onTransitionEnd()
end
end)
end)
self._stepSignalDisconnector = self.state.position:onStep(function(value)
if self._isMounted then
self:_onPositionStep(value)
end
end)
end
function Transitioner:didMount()
self._isMounted = true
end
function Transitioner:willUnmount()
self._isMounted = false
if self._completeSignalDisconnector then
self._completeSignalDisconnector()
self._completeSignalDisconnector = nil
end
if self._stepSignalDisconnector then
self._stepSignalDisconnector()
self._stepSignalDisconnector = nil
end
end
function Transitioner:didUpdate(prevProps)
-- React-navigation uses componentWillReceiveProps that is only called when Parent
-- re-renders or when this component is actually being given new props, so we need to
-- filter here. If not, this would trigger on setState and enter an infinite loop.
if self.props ~= prevProps then
if self._isTransitionRunning then
local mostRecentTransition = self._transitionQueue[#self._transitionQueue] or {}
-- don't enqueue spurious extra copies of same transition props
if mostRecentTransition.prevProps ~= prevProps then
table.insert(self._transitionQueue, { prevProps = prevProps })
end
return
end
self:_startTransition(prevProps, self.props)
end
end
function Transitioner:render()
return Roact.createElement("Frame", {
Size = UDim2.new(1, 0, 1, 0),
BackgroundTransparency = 1,
BorderSizePixel = 0,
ClipsDescendants = true,
[Roact.Change.AbsoluteSize] = self._doOnAbsoluteSizeChanged,
}, {
["TransitionerScenes"] = self.props.render(
self._transitionProps, self._prevTransitionProps),
})
end
-- equivalent to React-Nav's Transitioner._onLayout
function Transitioner:_onAbsoluteSizeChanged(rbx)
local width = rbx.AbsoluteSize.X
local height = rbx.AbsoluteSize.Y
if width == self.state.layout.initWidth and
height == self.state.layout.initHeight then
return
end
local layout = Cryo.Dictionary.join(self.state.layout, {
initWidth = width,
initHeight = height,
isMeasured = true,
})
local nextState = Cryo.Dictionary.join(self.state, {
layout = layout,
})
self._transitionProps = buildTransitionProps(self.props, nextState)
self:setState({
layout = layout,
})
end
function Transitioner:_computeScenes(props, nextProps)
local nextScenes = ScenesReducer(
self.state.scenes,
nextProps.navigation.state,
props.navigation.state,
nextProps.descriptors)
if not nextProps.navigation.state.isTransitioning then
nextScenes = filterStale(nextScenes)
end
if nextScenes == self.state.scenes then
return nil
end
return nextScenes
end
function Transitioner:_startTransition(props, nextProps)
local indexHasChanged = props.navigation.state.index ~= nextProps.navigation.state.index
local nextScenes = self:_computeScenes(props, nextProps)
if not nextScenes then
-- If nextScenes is nil, nothing has changed, so report transition end, then bail
self._prevTransitionProps = self._transitionProps
-- Trigger end transition logic if we daisy-chained from queued transitions via _onTransitionEnd.
if self._isTransitionRunning then
self:_onTransitionEnd()
end
return
end
local nextState = Cryo.Dictionary.join(self.state, {
scenes = nextScenes,
})
local position = nextState.position
local toValue = nextProps.navigation.state.index
-- compute transitionProps
self._prevTransitionProps = self._transitionProps
self._transitionProps = buildTransitionProps(nextProps, nextState)
local isTransitioning = self._transitionProps.navigation.state.isTransitioning
if not isTransitioning or not indexHasChanged then
-- If state is not transitioning, then we go immediately to new index.
-- Likewise, if the index has not changed then we still need to set up initial
-- positions via setState.
self:setState(nextState)
if nextProps.onTransitionStart then
nextProps.onTransitionStart(self._transitionProps, self._prevTransitionProps)
end
-- motor will call _endTransition for us
position:setGoal(Otter.instant(toValue))
elseif isTransitioning then
self._isTransitionRunning = true
self:setState(nextState)
if nextProps.onTransitionStart then
nextProps.onTransitionStart(self._transitionProps, self._prevTransitionProps)
end
local positionHasChanged = self._positionLastValue ~= toValue
if indexHasChanged and positionHasChanged then
-- get transition spec
local transitionUserSpec = {}
if nextProps.configureTransition then
transitionUserSpec = nextProps.configureTransition(
self._transitionProps, self._prevTransitionProps) or {}
end
local transitionSpec = Cryo.Dictionary.join(DEFAULT_TRANSITION_SPEC, transitionUserSpec)
-- motor will call _endTransition for us
position:setGoal(Otter.spring(nextProps.navigation.state.index, transitionSpec))
else
-- Set motor to current state to trigger _endTransition call with correct sequencing.
position:setGoal(Otter.instant(nextProps.navigation.state.index))
end
end
end
function Transitioner:_onTransitionEnd()
local prevTransitionProps = self._prevTransitionProps
self._prevTransitionProps = nil
local scenes = filterStale(self.state.scenes)
local nextState = Cryo.Dictionary.join(self.state, {
scenes = scenes,
})
self._transitionProps = buildTransitionProps(self.props, nextState)
self:setState(nextState)
if self.props.onTransitionEnd then
self.props.onTransitionEnd(self._transitionProps, prevTransitionProps)
end
local firstQueuedTransition = self._transitionQueue[1]
if firstQueuedTransition then
local prevProps = firstQueuedTransition.prevProps
self._transitionQueue = Cryo.List.removeIndex(self._transitionQueue, 1)
self:_startTransition(prevProps, self.props)
else
self._isTransitionRunning = false
end
end
function Transitioner:_onPositionStep(value)
self._positionLastValue = value
local targetIndex = self._transitionProps.index
-- _prevTransitionProps can be nil, so guard against it.
local startingIndex = targetIndex
if self._prevTransitionProps then
startingIndex = self._prevTransitionProps.index
end
if self.props.onTransitionStep and startingIndex ~= targetIndex then
local transitionValue = (value - startingIndex) / (targetIndex - startingIndex)
self.props.onTransitionStep(self._transitionProps, self._prevTransitionProps, transitionValue)
end
end
return Transitioner
@@ -0,0 +1,91 @@
return function()
local Roact = require(script.Parent.Parent.Parent.Parent.Roact)
local AppNavigationContext = require(script.Parent.Parent.AppNavigationContext)
it("should propagate navigation prop from provider to consumer", function()
local testNavigationContextProp = {}
local testComponentNavContext = nil
local provider = Roact.createElement(AppNavigationContext.Provider, {
navigation = testNavigationContextProp
}, {
Child = Roact.createElement(AppNavigationContext.Consumer, {
render = function(navigation)
testComponentNavContext = navigation
end
})
})
local instance = Roact.mount(provider)
expect(testComponentNavContext).to.equal(testNavigationContextProp)
Roact.unmount(instance)
end)
it("should override context navigation prop if custom prop is set on consumer", function()
local testCustomNavigationProp = {}
local testComponentNavContext = nil
local provider = Roact.createElement(AppNavigationContext.Provider, {
navigation = {}
}, {
Child = Roact.createElement(AppNavigationContext.Consumer, {
navigation = testCustomNavigationProp,
render = function(navigation)
testComponentNavContext = navigation
end
})
})
local instance = Roact.mount(provider)
expect(testComponentNavContext).to.equal(testCustomNavigationProp)
Roact.unmount(instance)
end)
it("should pass navigation prop to statically wrapped components", function()
local testNavigationContextProp = {}
local passedNavigationProp = nil
local TestComponent = Roact.Component:extend("TestComponent")
function TestComponent:render()
passedNavigationProp = self.props.navigation
end
local WrappedComponent = AppNavigationContext.connect(TestComponent)
local element = Roact.createElement(AppNavigationContext.Provider, {
navigation = testNavigationContextProp
}, {
Child = Roact.createElement(WrappedComponent)
})
local instance = Roact.mount(element)
Roact.unmount(instance)
expect(passedNavigationProp).to.equal(testNavigationContextProp)
end)
it("should override static wrapper navigation prop when navigation is directly set", function()
local testNavigationProp = {}
local passedNavigationProp = nil
local TestComponent = Roact.Component:extend("TestComponent")
function TestComponent:render()
passedNavigationProp = self.props.navigation
end
local WrappedComponent = AppNavigationContext.connect(TestComponent)
local element = Roact.createElement(AppNavigationContext.Provider, {
navigation = {}
}, {
Child = Roact.createElement(WrappedComponent, {
navigation = testNavigationProp
})
})
local instance = Roact.mount(element)
Roact.unmount(instance)
expect(passedNavigationProp).to.equal(testNavigationProp)
end)
end
@@ -0,0 +1,61 @@
return function()
local Roact = require(script.Parent.Parent.Parent.Parent.Roact)
local NavigationEvents = require(script.Parent.Parent.Parent.NavigationEvents)
local NavigationEventsAdapter = require(script.Parent.Parent.NavigationEventsAdapter)
it("should subscribe to events for each registered handler", function()
local mockNavContext = {
subscribedHandlers = {},
calledHandlers = {},
}
function mockNavContext:makeHandler(symbol)
return function()
self.calledHandlers[symbol] = true
end
end
function mockNavContext.addListener(symbol, callback)
mockNavContext.subscribedHandlers[symbol] = callback
return {
disconnect = function()
mockNavContext.subscribedHandlers[symbol] = nil
end
}
end
local adapter = Roact.createElement(NavigationEventsAdapter, {
navigation = mockNavContext,
[NavigationEvents.WillFocus] = mockNavContext:makeHandler(NavigationEvents.WillFocus),
[NavigationEvents.DidFocus] = mockNavContext:makeHandler(NavigationEvents.DidFocus),
[NavigationEvents.WillBlur] = mockNavContext:makeHandler(NavigationEvents.WillBlur),
[NavigationEvents.DidBlur] = mockNavContext:makeHandler(NavigationEvents.DidBlur),
})
local instance = Roact.mount(adapter)
expect(type(mockNavContext.subscribedHandlers[NavigationEvents.WillFocus])).to.equal("function")
expect(type(mockNavContext.subscribedHandlers[NavigationEvents.DidFocus])).to.equal("function")
expect(type(mockNavContext.subscribedHandlers[NavigationEvents.WillBlur])).to.equal("function")
expect(type(mockNavContext.subscribedHandlers[NavigationEvents.DidBlur])).to.equal("function")
mockNavContext.subscribedHandlers[NavigationEvents.WillFocus]()
expect(mockNavContext.calledHandlers[NavigationEvents.WillFocus]).to.equal(true)
mockNavContext.subscribedHandlers[NavigationEvents.DidFocus]()
expect(mockNavContext.calledHandlers[NavigationEvents.DidFocus]).to.equal(true)
mockNavContext.subscribedHandlers[NavigationEvents.WillBlur]()
expect(mockNavContext.calledHandlers[NavigationEvents.WillBlur]).to.equal(true)
mockNavContext.subscribedHandlers[NavigationEvents.DidBlur]()
expect(mockNavContext.calledHandlers[NavigationEvents.DidBlur]).to.equal(true)
Roact.unmount(instance)
expect(mockNavContext.subscribedHandlers[NavigationEvents.WillFocus]).to.equal(nil)
expect(mockNavContext.subscribedHandlers[NavigationEvents.DidFocus]).to.equal(nil)
expect(mockNavContext.subscribedHandlers[NavigationEvents.WillBlur]).to.equal(nil)
expect(mockNavContext.subscribedHandlers[NavigationEvents.DidBlur]).to.equal(nil)
end)
end
@@ -0,0 +1,36 @@
return function()
local Roact = require(script.Parent.Parent.Parent.Parent.Roact)
local SceneView = require(script.Parent.Parent.SceneView)
local withNavigation = require(script.Parent.Parent.withNavigation)
it("should mount inner component and pass down required props+context.navigation", function()
local testComponentNavigationFromProp = nil
local testComponentScreenProps = nil
local testComponentNavigationFromContext = nil
local TestComponent = Roact.Component:extend("TestComponent")
function TestComponent:render()
testComponentNavigationFromProp = self.props.navigation
testComponentScreenProps = self.props.screenProps
return withNavigation(function(navigation)
testComponentNavigationFromContext = navigation
end)
end
local testScreenProps = {}
local testNav = {}
local element = Roact.createElement(SceneView, {
screenProps = testScreenProps,
navigation = testNav,
component = TestComponent,
})
local instance = Roact.mount(element)
Roact.unmount(instance)
expect(testComponentScreenProps).to.equal(testScreenProps)
expect(testComponentNavigationFromProp).to.equal(testNav)
expect(testComponentNavigationFromContext).to.equal(testNav)
end)
end
@@ -0,0 +1,266 @@
return function()
-- ScenesReducer(scenes, nextState, prevState, descriptors)
local ScenesReducer = require(script.Parent.Parent.ScenesReducer)
local initialRouteKey = "id-1"
local initialRouteName = "First Route"
local initialRoute = {
routeName = initialRouteName,
key = initialRouteKey,
}
local initialState = {
index = 1,
key = "StackRouterRoot",
isTransitioning = false,
routes = {
initialRoute,
},
}
local initialDescriptors = {
[initialRouteKey] = {
key = initialRouteKey,
navigation = {
state = initialRoute,
},
state = initialRoute,
},
}
local initialScenes = nil
it("should generate valid initial scene", function()
local scenes = ScenesReducer({}, initialState, nil, initialDescriptors)
local sceneOne = scenes[1]
expect(#scenes).to.be.equal(1)
expect(sceneOne.route).to.be.equal(initialRoute)
expect(sceneOne.index).to.be.equal(1)
expect(sceneOne.isActive).to.be.equal(true)
expect(sceneOne.isStale).to.be.equal(false)
initialScenes = scenes
end)
it("should update descriptor", function()
local scenes = ScenesReducer({}, initialState, nil, initialDescriptors)
local dummyDescriptor = { key = "this is a dummy descriptor" }
scenes[1].descriptor = dummyDescriptor
local updatedScenes = ScenesReducer(scenes, initialState, initialState, initialDescriptors)
expect(#updatedScenes).to.be.equal(1)
local sceneOne = updatedScenes[1]
expect(sceneOne.descriptor).to.never.be.equal(dummyDescriptor)
expect(sceneOne.descriptor).to.be.equal(initialDescriptors[initialRouteKey])
expect(sceneOne.route).to.be.equal(initialRoute)
expect(sceneOne.index).to.be.equal(1)
expect(sceneOne.isActive).to.be.equal(true)
expect(sceneOne.isStale).to.be.equal(false)
end)
it("should bail out early", function()
expect(initialScenes).to.never.be.equal(nil)
local scenes = ScenesReducer(initialScenes, nil, nil, nil)
expect(scenes).to.be.equal(initialScenes)
scenes = ScenesReducer(initialScenes, initialState, initialState, initialDescriptors)
expect(scenes).to.be.equal(initialScenes)
end)
local secondRouteKey = "id-2"
local secondRouteName = "Second Route"
local secondRoute = {
key = secondRouteKey,
routeName = secondRouteName,
}
local secondState = {
index = 2,
key = "StackRouterRoot",
isTransitioning = true,
routes = {
initialRoute,
secondRoute,
},
}
local secondDescriptors = {
[initialRouteKey] = initialDescriptors[initialRouteKey],
[secondRouteKey] ={
key = secondRouteKey,
navigation = {
state = secondRoute,
},
state = secondRoute,
},
}
local secondScenes = nil
it("should add second scene", function()
expect(initialScenes).to.never.be.equal(nil)
local scenes = ScenesReducer(initialScenes, secondState, initialState, secondDescriptors)
expect(scenes).to.never.be.equal(initialScenes)
expect(#scenes).to.be.equal(2)
local sceneOne = scenes[1]
expect(sceneOne.route).to.be.equal(initialRoute)
expect(sceneOne.index).to.be.equal(1)
expect(sceneOne.isActive).to.be.equal(false)
expect(sceneOne.isStale).to.be.equal(false)
local sceneTwo = scenes[2]
expect(sceneTwo.route).to.be.equal(secondRoute)
expect(sceneTwo.index).to.be.equal(2)
expect(sceneTwo.isActive).to.be.equal(true)
expect(sceneTwo.isStale).to.be.equal(false)
secondScenes = scenes
end)
local thirdRouteKey = "id-3"
local thirdRouteName = "Third Route"
local thirdRoute = {
key = thirdRouteKey,
routeName = thirdRouteName,
}
local thirdState = {
index = 3,
key = "StackRouterRoot",
isTransitioning = true,
routes = {
initialRoute,
secondRoute,
thirdRoute,
},
}
local thirdDescriptors = {
[initialRouteKey] = initialDescriptors[initialRouteKey],
[secondRouteKey] = secondDescriptors[secondRouteKey],
[thirdRouteKey] = {
key = thirdRouteKey,
navigation = {
state = thirdRoute,
},
state = thirdRoute,
},
}
local thirdScenes = nil
it("should add third scene", function()
expect(initialScenes).to.never.be.equal(nil)
local scenes = ScenesReducer(secondScenes, thirdState, secondState, thirdDescriptors)
expect(scenes).to.never.be.equal(initialScenes)
expect(#scenes).to.be.equal(3)
local sceneOne = scenes[1]
expect(sceneOne.route).to.be.equal(initialRoute)
expect(sceneOne.index).to.be.equal(1)
expect(sceneOne.isActive).to.be.equal(false)
expect(sceneOne.isStale).to.be.equal(false)
local sceneTwo = scenes[2]
expect(sceneTwo.route).to.be.equal(secondRoute)
expect(sceneTwo.index).to.be.equal(2)
expect(sceneTwo.isActive).to.be.equal(false)
expect(sceneTwo.isStale).to.be.equal(false)
local sceneTwo = scenes[3]
expect(sceneTwo.route).to.be.equal(thirdRoute)
expect(sceneTwo.index).to.be.equal(3)
expect(sceneTwo.isActive).to.be.equal(true)
expect(sceneTwo.isStale).to.be.equal(false)
thirdScenes = scenes
end)
it("should mark removed scenes as stale", function()
expect(secondState).to.never.be.equal(nil)
local scenes = ScenesReducer(thirdScenes, initialState, thirdState, initialDescriptors)
expect(scenes).to.never.be.equal(initialScenes)
expect(#scenes).to.be.equal(3)
local sceneOne = scenes[1]
expect(sceneOne.route).to.be.equal(initialRoute)
expect(sceneOne.index).to.be.equal(1)
expect(sceneOne.isActive).to.be.equal(true)
expect(sceneOne.isStale).to.be.equal(false)
local sceneTwo = scenes[2]
expect(sceneTwo.route).to.be.equal(secondRoute)
expect(sceneTwo.index).to.be.equal(2)
expect(sceneTwo.isActive).to.be.equal(false)
expect(sceneTwo.isStale).to.be.equal(true)
local sceneThree = scenes[3]
expect(sceneThree.route).to.be.equal(thirdRoute)
expect(sceneThree.index).to.be.equal(3)
expect(sceneThree.isActive).to.be.equal(false)
expect(sceneThree.isStale).to.be.equal(true)
end)
local secondScreenReplacementKey = "id-22"
local secondScreenReplacementName = "Second Route Replacement"
local secondScreenReplacementRoute = {
key = secondScreenReplacementKey,
routeName = secondScreenReplacementName,
}
local replacedSecondSceneState = {
index = 3,
key = "StackRouterRoot",
isTransitioning = true,
routes = {
initialRoute,
secondScreenReplacementRoute,
thirdRoute,
},
}
local replacedSceneDescriptors = {
[initialRouteKey] = initialDescriptors[initialRouteKey],
[secondRouteKey] = {
key = secondScreenReplacementKey,
navigation = {
state = secondScreenReplacementRoute
},
state = secondScreenReplacementRoute,
},
[thirdRouteKey] = thirdDescriptors[thirdRouteKey],
}
it("should mark replaced scene as stale", function()
expect(secondState).to.never.be.equal(nil)
local scenes = ScenesReducer(thirdScenes, replacedSecondSceneState, thirdState, replacedSceneDescriptors)
expect(scenes).to.never.be.equal(initialScenes)
expect(#scenes).to.be.equal(4) -- replaced scene is marked stale, it is not removed
local sceneOne = scenes[1]
expect(sceneOne.route).to.be.equal(initialRoute)
expect(sceneOne.index).to.be.equal(1)
expect(sceneOne.isActive).to.be.equal(false)
expect(sceneOne.isStale).to.be.equal(false)
local sceneTwo = scenes[2]
expect(sceneTwo.route).to.be.equal(secondRoute)
expect(sceneTwo.index).to.be.equal(2)
expect(sceneTwo.isActive).to.be.equal(false)
expect(sceneTwo.isStale).to.be.equal(true)
-- because of comparison algorithm in SceneReducer.lua compareScenes
-- the replacement scene is after the scene it replaced because id-2 < id-22
local sceneTwoReplacement = scenes[3]
expect(sceneTwoReplacement.route).to.be.equal(secondScreenReplacementRoute)
-- index is still 2 because the scene index come from route index in the nextState
-- this is ok because filterStale in Transitioner.lua will remove the stale scene
expect(sceneTwoReplacement.index).to.be.equal(2)
expect(sceneTwoReplacement.isActive).to.be.equal(false)
expect(sceneTwoReplacement.isStale).to.be.equal(false)
local sceneThree = scenes[4]
expect(sceneThree.route).to.be.equal(thirdRoute)
expect(sceneThree.index).to.be.equal(3)
expect(sceneThree.isActive).to.be.equal(true)
expect(sceneThree.isStale).to.be.equal(false)
end)
end
@@ -0,0 +1,50 @@
return function()
local Roact = require(script.Parent.Parent.Parent.Parent.Roact)
local ScreenGuiWrapper = require(script.Parent.Parent.ScreenGuiWrapper)
it("should mount the inner component if visible", function()
local innerComponentProps = nil
local innerComponent = function(props)
innerComponentProps = props
return Roact.createElement("Frame", {
Size = UDim2.new(0, 50, 0, 50)
})
end
local instance = Roact.mount(Roact.createElement(ScreenGuiWrapper, {
component = innerComponent,
visible = true,
myPassedInValue = 5,
}))
expect(innerComponentProps).to.never.equal(nil)
expect(innerComponentProps.visible).to.equal(true)
expect(innerComponentProps.DisplayOrder).to.equal(nil)
expect(innerComponentProps.component).to.equal(nil)
expect(innerComponentProps.myPassedInValue).to.equal(5)
Roact.unmount(instance)
end)
it("should still mount the inner component if ScreenGui is not visible", function()
local innerComponentProps = nil
local innerComponent = function(props)
innerComponentProps = props
return Roact.createElement("Frame", {
Size = UDim2.new(0, 50, 0, 50)
})
end
local instance = Roact.mount(Roact.createElement(ScreenGuiWrapper, {
component = innerComponent,
visible = false,
}))
expect(innerComponentProps).to.never.equal(nil)
expect(innerComponentProps.visible).to.equal(false)
Roact.unmount(instance)
end)
end
@@ -0,0 +1,274 @@
return function()
local Roact = require(script.Parent.Parent.Parent.Parent.Roact)
local SwitchView = require(script.Parent.Parent.SwitchView)
local withNavigation = require(script.Parent.Parent.withNavigation)
it("should mount and pass required props and context", function()
local testScreenProps = {}
local testNavigation = {
state = {
routes = {
{ routeName = "Foo", key = "Foo" }
},
index = 1,
},
}
local testComponentNavigationFromProp = nil
local testComponentScreenProps = nil
local testComponentNavigationFromContext = nil
local TestComponent = Roact.Component:extend("TestComponent")
function TestComponent:render()
testComponentNavigationFromProp = self.props.navigation
testComponentScreenProps = self.props.screenProps
return withNavigation(function(navigation)
testComponentNavigationFromContext = navigation
end)
end
local testDescriptors = {
Foo = {
getComponent = function()
return TestComponent
end,
navigation = testNavigation,
}
}
local element = Roact.createElement(SwitchView, {
screenProps = testScreenProps,
navigation = testNavigation,
descriptors = testDescriptors,
})
local instance = Roact.mount(element)
Roact.unmount(instance)
expect(testComponentNavigationFromProp).to.equal(testNavigation)
expect(testComponentScreenProps).to.equal(testScreenProps)
expect(testComponentNavigationFromContext).to.equal(testNavigation)
end)
it("should unmount inactive pages when keepVisitedScreensMounted is false", function()
local fooUnmounted = false
local TestComponentFoo = Roact.Component:extend("TestComponentFoo")
function TestComponentFoo:render() end
function TestComponentFoo:willUnmount()
fooUnmounted = true
end
local TestComponentBar = Roact.Component:extend("TestComponentBar")
function TestComponentBar:render() end
local function makeDescriptors(navProp)
return {
Foo = {
getComponent = function() return TestComponentFoo end,
navigation = navProp,
},
Bar = {
getComponent = function() return TestComponentBar end,
navigation = navProp,
},
}
end
local testNavigation1 = {
state = {
routes = {
{ routeName = "Foo", key = "Foo" },
{ routeName = "Bar", key = "Bar" },
},
index = 1,
},
}
local element = Roact.createElement(SwitchView, {
screenProps = {},
navigation = testNavigation1,
descriptors = makeDescriptors(testNavigation1),
navigationConfig = {
keepVisitedScreensMounted = false,
},
})
local instance = Roact.mount(element)
expect(fooUnmounted).to.equal(false)
local testNavigation2 = {
state = {
routes = {
{ routeName = "Foo", key = "Foo" },
{ routeName = "Bar", key = "Bar" },
},
index = 2,
},
}
instance = Roact.update(instance, Roact.createElement(SwitchView, {
screenProps = {},
navigation = testNavigation2,
descriptors = makeDescriptors(testNavigation2),
navigationConfig = {
keepVisitedScreensMounted = false,
}
}))
expect(fooUnmounted).to.equal(true)
Roact.unmount(instance)
end)
it("should not unmount inactive pages when keepVisitedScreensMounted is true", function()
local fooUnmounted = false
local TestComponentFoo = Roact.Component:extend("TestComponentFoo")
function TestComponentFoo:render() end
function TestComponentFoo:willUnmount()
fooUnmounted = true
end
local TestComponentBar = Roact.Component:extend("TestComponentBar")
function TestComponentBar:render() end
local function makeDescriptors(navProp)
return {
Foo = {
getComponent = function() return TestComponentFoo end,
navigation = navProp,
},
Bar = {
getComponent = function() return TestComponentBar end,
navigation = navProp,
},
}
end
local testNavigation1 = {
state = {
routes = {
{ routeName = "Foo", key = "Foo" },
{ routeName = "Bar", key = "Bar" },
},
index = 1,
},
}
local element = Roact.createElement(SwitchView, {
screenProps = {},
navigation = testNavigation1,
descriptors = makeDescriptors(testNavigation1),
navigationConfig = {
keepVisitedScreensMounted = true,
},
})
local instance = Roact.mount(element)
expect(fooUnmounted).to.equal(false)
local testNavigation2 = {
state = {
routes = {
{ routeName = "Foo", key = "Foo" },
{ routeName = "Bar", key = "Bar" },
},
index = 2,
},
}
instance = Roact.update(instance, Roact.createElement(SwitchView, {
screenProps = {},
navigation = testNavigation2,
descriptors = makeDescriptors(testNavigation2),
navigationConfig = {
keepVisitedScreensMounted = true,
}
}))
expect(fooUnmounted).to.equal(false)
Roact.unmount(instance)
expect(fooUnmounted).to.equal(true)
end)
it("should unmount inactive pages when keepVisitedScreensMounted switches from true to false", function()
local fooUnmounted = false
local TestComponentFoo = Roact.Component:extend("TestComponentFoo")
function TestComponentFoo:render() end
function TestComponentFoo:willUnmount()
fooUnmounted = true
end
local TestComponentBar = Roact.Component:extend("TestComponentBar")
function TestComponentBar:render() end
local function makeDescriptors(navProp)
return {
Foo = {
getComponent = function() return TestComponentFoo end,
navigation = navProp,
},
Bar = {
getComponent = function() return TestComponentBar end,
navigation = navProp,
},
}
end
local testNavigation1 = {
state = {
routes = {
{ routeName = "Foo", key = "Foo" },
{ routeName = "Bar", key = "Bar" },
},
index = 1,
},
}
local element = Roact.createElement(SwitchView, {
screenProps = {},
navigation = testNavigation1,
descriptors = makeDescriptors(testNavigation1),
navigationConfig = {
keepVisitedScreensMounted = true,
},
})
local instance = Roact.mount(element)
local testNavigation2 = {
state = {
routes = {
{ routeName = "Foo", key = "Foo" },
{ routeName = "Bar", key = "Bar" },
},
index = 2,
},
}
-- We must update tree to make sure active screens list gets updated first!
instance = Roact.update(instance, Roact.createElement(SwitchView, {
screenProps = {},
navigation = testNavigation2,
descriptors = makeDescriptors(testNavigation2),
navigationConfig = {
keepVisitedScreensMounted = true,
}
}))
expect(fooUnmounted).to.equal(false)
instance = Roact.update(instance, Roact.createElement(SwitchView, {
screenProps = {},
navigation = testNavigation2,
descriptors = makeDescriptors(testNavigation2),
navigationConfig = {
keepVisitedScreensMounted = false,
}
}))
expect(fooUnmounted).to.equal(true)
Roact.unmount(instance)
end)
end
@@ -0,0 +1,8 @@
return function()
-- local Roact = require(script.Parent.Parent.Parent.Parent.Roact)
-- local Transitioner = require(script.Parent.Parent.Transitioner)
itSKIP("should have its tests implemented", function()
end)
end
@@ -0,0 +1,34 @@
return function()
local Roact = require(script.Parent.Parent.Parent.Parent.Roact)
local withNavigation = require(script.Parent.Parent.withNavigation)
local AppNavigationContext = require(script.Parent.Parent.AppNavigationContext)
it("should throw if no renderProp is provided", function()
local status, err = pcall(function()
withNavigation(nil)
end)
expect(status).to.equal(false)
expect(string.find(err, "withNavigation must be passed a render prop")).to.never.equal(nil)
end)
it("should extract navigation object from provider and pass it through", function()
local testNavigation = {}
local extractedNavigation = nil
local rootElement = Roact.createElement(AppNavigationContext.Provider, {
navigation = testNavigation,
}, {
Child = Roact.createElement(function()
return withNavigation(function(nav)
extractedNavigation = nav
end)
end)
})
local rootInstance = Roact.mount(rootElement)
Roact.unmount(rootInstance)
expect(extractedNavigation).to.equal(testNavigation)
end)
end
@@ -0,0 +1,147 @@
return function()
local Roact = require(script.Parent.Parent.Parent.Parent.Roact)
local AppNavigationContext = require(script.Parent.Parent.AppNavigationContext)
local NavigationEvents = require(script.Parent.Parent.Parent.NavigationEvents)
local withNavigationFocus = require(script.Parent.Parent.withNavigationFocus)
it("should pass focused=true when initially focused", function()
local testNavigation, testFocused
local component = function()
return withNavigationFocus(function(navigation, focused)
testNavigation = navigation
testFocused = focused
return nil
end)
end
local navigationProp = {
isFocused = function()
return true
end,
addListener = function()
return {
disconnect = function() end
}
end
}
local rootElement = Roact.createElement(AppNavigationContext.Provider, {
navigation = navigationProp,
}, {
child = Roact.createElement(component)
})
local instance = Roact.mount(rootElement)
expect(testNavigation).to.equal(navigationProp)
expect(testFocused).to.equal(true)
Roact.unmount(instance)
end)
it("should pass focused=false when initially unfocused", function()
local testNavigation, testFocused
local component = function()
return withNavigationFocus(function(navigation, focused)
testNavigation = navigation
testFocused = focused
return nil
end)
end
local navigationProp = {
isFocused = function()
return false
end,
addListener = function()
return {
disconnect = function() end
}
end
}
local rootElement = Roact.createElement(AppNavigationContext.Provider, {
navigation = navigationProp,
}, {
child = Roact.createElement(component)
})
local instance = Roact.mount(rootElement)
expect(testNavigation).to.equal(navigationProp)
expect(testFocused).to.equal(false)
Roact.unmount(instance)
end)
it("should re-render and set focused status for events", function()
local testListeners = {}
local testFocused = false
local component = function()
return withNavigationFocus(function(navigation, focused)
testFocused = focused
return nil
end)
end
local navigationProp = {
isFocused = function()
return false
end,
addListener = function(event, listener)
testListeners[event] = listener
return {
disconnect = function()
testListeners[event] = nil
end
}
end
}
local rootElement = Roact.createElement(AppNavigationContext.Provider, {
navigation = navigationProp,
}, {
child = Roact.createElement(component)
})
local instance = Roact.mount(rootElement)
expect(testFocused).to.equal(false)
expect(type(testListeners[NavigationEvents.DidFocus])).to.equal("function")
expect(type(testListeners[NavigationEvents.WillBlur])).to.equal("function")
testListeners[NavigationEvents.DidFocus]()
expect(testFocused).to.equal(true)
testListeners[NavigationEvents.WillBlur]()
expect(testFocused).to.equal(false)
Roact.unmount(instance)
expect(testListeners[NavigationEvents.DidFocus]).to.equal(nil)
expect(testListeners[NavigationEvents.WillBlur]).to.equal(nil)
end)
it("should throw when renderProp is not provided", function()
local success, err = pcall(function()
withNavigationFocus(nil)
end)
expect(success).to.equal(false)
expect(string.find(err,
"withNavigationFocus must be passed a render prop")).to.never.equal(nil)
end)
it("should throw when used outside of a navigation provider", function()
local component = function()
return withNavigationFocus(function(navigation, focused)
end)
end
local element = Roact.createElement(component)
local success, _ = pcall(function()
Roact.unmount(Roact.mount(element))
end)
expect(success).to.equal(false)
-- We do not test the message because NavigationConsumer gets in the way here.
end)
end

Some files were not shown because too many files have changed in this diff Show More