@lattice-ui/core 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,14 @@
1
+ import type ReactType from "@rbxts/react";
2
+ type FocusScopeProviderProps = {
3
+ scopeId?: number;
4
+ children?: ReactType.ReactNode;
5
+ };
6
+ type FocusLayerProviderProps = {
7
+ layerOrder?: number;
8
+ children?: ReactType.ReactNode;
9
+ };
10
+ export declare function FocusScopeProvider(props: FocusScopeProviderProps): ReactType.JSX.Element;
11
+ export declare function useFocusScopeId(): number | undefined;
12
+ export declare function FocusLayerProvider(props: FocusLayerProviderProps): ReactType.JSX.Element;
13
+ export declare function useFocusLayerOrder(): number | undefined;
14
+ export {};
@@ -0,0 +1,27 @@
1
+ -- Compiled with roblox-ts v3.0.0
2
+ local TS = _G[script]
3
+ local React = TS.import(script, script.Parent.Parent, "react").default
4
+ local FocusScopeIdContext = React.createContext(nil)
5
+ local FocusLayerOrderContext = React.createContext(nil)
6
+ local function FocusScopeProvider(props)
7
+ return React.createElement(FocusScopeIdContext.Provider, {
8
+ value = props.scopeId,
9
+ }, props.children)
10
+ end
11
+ local function useFocusScopeId()
12
+ return React.useContext(FocusScopeIdContext)
13
+ end
14
+ local function FocusLayerProvider(props)
15
+ return React.createElement(FocusLayerOrderContext.Provider, {
16
+ value = props.layerOrder,
17
+ }, props.children)
18
+ end
19
+ local function useFocusLayerOrder()
20
+ return React.useContext(FocusLayerOrderContext)
21
+ end
22
+ return {
23
+ FocusScopeProvider = FocusScopeProvider,
24
+ useFocusScopeId = useFocusScopeId,
25
+ FocusLayerProvider = FocusLayerProvider,
26
+ useFocusLayerOrder = useFocusLayerOrder,
27
+ }
@@ -0,0 +1 @@
1
+ export declare const GuiService: GuiService;
@@ -0,0 +1,5 @@
1
+ -- Compiled with roblox-ts v3.0.0
2
+ local GuiService = game:GetService("GuiService")
3
+ return {
4
+ GuiService = GuiService,
5
+ }
@@ -0,0 +1,44 @@
1
+ export type FocusRestoreSnapshot = {
2
+ nodeId?: number;
3
+ };
4
+ export type FocusNodeRecord = {
5
+ id: number;
6
+ scopeId?: number;
7
+ implicit: boolean;
8
+ order: number;
9
+ getGuiObject: () => GuiObject | undefined;
10
+ getDisabled: () => boolean;
11
+ getVisible: () => boolean | undefined;
12
+ getSyncToRoblox: () => boolean;
13
+ };
14
+ export type RegisterFocusNodeParams = {
15
+ scopeId?: number;
16
+ getGuiObject: () => GuiObject | undefined;
17
+ getDisabled?: () => boolean;
18
+ getVisible?: () => boolean | undefined;
19
+ getSyncToRoblox?: () => boolean;
20
+ };
21
+ export type RegisterFocusScopeParams = {
22
+ parentScopeId?: number;
23
+ getRoot: () => GuiObject | undefined;
24
+ getActive: () => boolean;
25
+ getTrapped: () => boolean;
26
+ getRestoreFocus?: () => boolean;
27
+ getLayerOrder?: () => number | undefined;
28
+ };
29
+ export declare function registerFocusNode(params: RegisterFocusNodeParams): number;
30
+ export declare function createFocusScopeId(): number;
31
+ export declare function unregisterFocusNode(nodeId: number): void;
32
+ export declare function registerFocusScope(scopeId: number, params: RegisterFocusScopeParams): number;
33
+ export declare function syncFocusScope(scopeId: number): void;
34
+ export declare function unregisterFocusScope(scopeId: number): void;
35
+ export declare function retainExternalFocusBridge(): void;
36
+ export declare function releaseExternalFocusBridge(): void;
37
+ export declare function canFocusNode(nodeId: number): boolean;
38
+ export declare function focusNode(nodeId: number): GuiObject | undefined;
39
+ export declare function focusGuiObject(guiObject: GuiObject | undefined): GuiObject | undefined;
40
+ export declare function clearFocus(): void;
41
+ export declare function getFocusedNode(): FocusNodeRecord | undefined;
42
+ export declare function getFocusedGuiObject(): GuiObject | undefined;
43
+ export declare function captureRestoreSnapshot(): FocusRestoreSnapshot;
44
+ export declare function restoreSnapshot(snapshot: FocusRestoreSnapshot | undefined): GuiObject | undefined;
@@ -0,0 +1,727 @@
1
+ -- Compiled with roblox-ts v3.0.0
2
+ local TS = _G[script]
3
+ local GuiService = TS.import(script, script.Parent, "env").GuiService
4
+ local focusNodes = {}
5
+ local focusScopes = {}
6
+ local nextFocusNodeId = 0
7
+ local nextFocusScopeId = 0
8
+ local nextFocusOrder = 0
9
+ local currentFocusedNodeId
10
+ local externalSelectionConsumerCount = 0
11
+ local selectedObjectConnection
12
+ local bridgeWriteDepth = 0
13
+ local function isLiveGuiObject(guiObject)
14
+ return guiObject ~= nil and guiObject.Parent ~= nil
15
+ end
16
+ local function isEffectivelyVisible(guiObject)
17
+ if not isLiveGuiObject(guiObject) or not guiObject.Visible then
18
+ return false
19
+ end
20
+ local ancestor = guiObject.Parent
21
+ while ancestor ~= nil do
22
+ if ancestor:IsA("GuiObject") and not ancestor.Visible then
23
+ return false
24
+ end
25
+ if ancestor:IsA("LayerCollector") and not ancestor.Enabled then
26
+ return false
27
+ end
28
+ ancestor = ancestor.Parent
29
+ end
30
+ return true
31
+ end
32
+ local function isInsideRoot(scopeRoot, guiObject)
33
+ if not isLiveGuiObject(scopeRoot) or not isLiveGuiObject(guiObject) then
34
+ return false
35
+ end
36
+ return guiObject == scopeRoot or guiObject:IsDescendantOf(scopeRoot)
37
+ end
38
+ local function isRawGuiObjectFocusable(guiObject)
39
+ return isLiveGuiObject(guiObject) and isEffectivelyVisible(guiObject) and guiObject.Selectable
40
+ end
41
+ local function findFocusNodeIndex(nodeId)
42
+ -- ▼ ReadonlyArray.findIndex ▼
43
+ local _callback = function(entry)
44
+ return entry.id == nodeId
45
+ end
46
+ local _result = -1
47
+ for _i, _v in focusNodes do
48
+ if _callback(_v, _i - 1, focusNodes) == true then
49
+ _result = _i - 1
50
+ break
51
+ end
52
+ end
53
+ -- ▲ ReadonlyArray.findIndex ▲
54
+ return _result
55
+ end
56
+ local function findFocusScopeIndex(scopeId)
57
+ -- ▼ ReadonlyArray.findIndex ▼
58
+ local _callback = function(entry)
59
+ return entry.id == scopeId
60
+ end
61
+ local _result = -1
62
+ for _i, _v in focusScopes do
63
+ if _callback(_v, _i - 1, focusScopes) == true then
64
+ _result = _i - 1
65
+ break
66
+ end
67
+ end
68
+ -- ▲ ReadonlyArray.findIndex ▲
69
+ return _result
70
+ end
71
+ local function getFocusNodeRecord(nodeId)
72
+ if nodeId == nil then
73
+ return nil
74
+ end
75
+ local nodeIndex = findFocusNodeIndex(nodeId)
76
+ if nodeIndex < 0 then
77
+ return nil
78
+ end
79
+ return focusNodes[nodeIndex + 1]
80
+ end
81
+ local function getFocusScopeRecord(scopeId)
82
+ if scopeId == nil then
83
+ return nil
84
+ end
85
+ local scopeIndex = findFocusScopeIndex(scopeId)
86
+ if scopeIndex < 0 then
87
+ return nil
88
+ end
89
+ return focusScopes[scopeIndex + 1]
90
+ end
91
+ local function getNodeOwningScopeId(record, guiObject)
92
+ if record.scopeId ~= nil then
93
+ return record.scopeId
94
+ end
95
+ if not isLiveGuiObject(guiObject) then
96
+ return nil
97
+ end
98
+ local bestScopeId
99
+ local bestLayerOrder = -1
100
+ local bestScopeOrder = -1
101
+ for _, scopeRecord in focusScopes do
102
+ local scopeRoot = scopeRecord.getRoot()
103
+ if not isInsideRoot(scopeRoot, guiObject) then
104
+ continue
105
+ end
106
+ local _condition = scopeRecord.getLayerOrder()
107
+ if _condition == nil then
108
+ _condition = 0
109
+ end
110
+ local layerOrder = _condition
111
+ if layerOrder > bestLayerOrder or (layerOrder == bestLayerOrder and scopeRecord.order > bestScopeOrder) then
112
+ bestScopeId = scopeRecord.id
113
+ bestLayerOrder = layerOrder
114
+ bestScopeOrder = scopeRecord.order
115
+ end
116
+ end
117
+ return bestScopeId
118
+ end
119
+ local function isNodeInsideInactiveScope(record, guiObject)
120
+ local owningScopeId = getNodeOwningScopeId(record, guiObject)
121
+ local owningScope = getFocusScopeRecord(owningScopeId)
122
+ return owningScope ~= nil and not owningScope.getActive()
123
+ end
124
+ local function getTopTrappedScope(excludingScopeId)
125
+ local bestScope
126
+ for _, scopeRecord in focusScopes do
127
+ if scopeRecord.id == excludingScopeId or not scopeRecord.getActive() or not scopeRecord.getTrapped() then
128
+ continue
129
+ end
130
+ local scopeRoot = scopeRecord.getRoot()
131
+ if not isLiveGuiObject(scopeRoot) then
132
+ continue
133
+ end
134
+ if not bestScope then
135
+ bestScope = scopeRecord
136
+ continue
137
+ end
138
+ local _condition = bestScope.getLayerOrder()
139
+ if _condition == nil then
140
+ _condition = 0
141
+ end
142
+ local bestLayerOrder = _condition
143
+ local _condition_1 = scopeRecord.getLayerOrder()
144
+ if _condition_1 == nil then
145
+ _condition_1 = 0
146
+ end
147
+ local nextLayerOrder = _condition_1
148
+ if nextLayerOrder > bestLayerOrder or (nextLayerOrder == bestLayerOrder and scopeRecord.order > bestScope.order) then
149
+ bestScope = scopeRecord
150
+ end
151
+ end
152
+ return bestScope
153
+ end
154
+ local function getResolvedFocusNode(nodeId, options)
155
+ local record = getFocusNodeRecord(nodeId)
156
+ if not record then
157
+ return nil
158
+ end
159
+ local guiObject = record.getGuiObject()
160
+ if not isLiveGuiObject(guiObject) then
161
+ return nil
162
+ end
163
+ if record.getDisabled() then
164
+ return nil
165
+ end
166
+ local explicitVisible = record.getVisible()
167
+ if explicitVisible == false or not isEffectivelyVisible(guiObject) then
168
+ return nil
169
+ end
170
+ if isNodeInsideInactiveScope(record, guiObject) then
171
+ return nil
172
+ end
173
+ local _result = options
174
+ if _result ~= nil then
175
+ _result = _result.trapScopeOverride
176
+ end
177
+ local _condition = _result
178
+ if _condition == nil then
179
+ _condition = getTopTrappedScope()
180
+ end
181
+ local trappedScope = _condition
182
+ if trappedScope and not isInsideRoot(trappedScope.getRoot(), guiObject) then
183
+ return nil
184
+ end
185
+ return {
186
+ record = record,
187
+ guiObject = guiObject,
188
+ }
189
+ end
190
+ local function canSyncNodeToRoblox(resolvedNode)
191
+ return resolvedNode.record.getSyncToRoblox() and resolvedNode.guiObject.Selectable
192
+ end
193
+ local function withBridgeWrite(callback)
194
+ bridgeWriteDepth += 1
195
+ local result = callback()
196
+ bridgeWriteDepth -= 1
197
+ return result
198
+ end
199
+ local function syncRobloxSelection()
200
+ local resolvedFocusedNode = if currentFocusedNodeId ~= nil then getResolvedFocusNode(currentFocusedNodeId) else nil
201
+ local nextSelectedObject = if resolvedFocusedNode and canSyncNodeToRoblox(resolvedFocusedNode) then resolvedFocusedNode.guiObject else nil
202
+ if GuiService.SelectedObject == nextSelectedObject then
203
+ return nil
204
+ end
205
+ withBridgeWrite(function()
206
+ GuiService.SelectedObject = nextSelectedObject
207
+ end)
208
+ end
209
+ local function updateScopeLastFocusedNode(nodeId, guiObject)
210
+ for _, scopeRecord in focusScopes do
211
+ if not scopeRecord.getActive() then
212
+ continue
213
+ end
214
+ if isInsideRoot(scopeRecord.getRoot(), guiObject) then
215
+ scopeRecord.lastFocusedNodeId = nodeId
216
+ end
217
+ end
218
+ end
219
+ local function setCurrentFocusedNode(nodeId)
220
+ currentFocusedNodeId = nodeId
221
+ local resolvedNode = if nodeId ~= nil then getResolvedFocusNode(nodeId) else nil
222
+ if resolvedNode then
223
+ updateScopeLastFocusedNode(resolvedNode.record.id, resolvedNode.guiObject)
224
+ end
225
+ syncRobloxSelection()
226
+ local _result = resolvedNode
227
+ if _result ~= nil then
228
+ _result = _result.guiObject
229
+ end
230
+ return _result
231
+ end
232
+ local function findExistingFocusNodeByGuiObject(guiObject)
233
+ if not isLiveGuiObject(guiObject) then
234
+ return nil
235
+ end
236
+ for index = #focusNodes - 1, 0, -1 do
237
+ local nodeRecord = focusNodes[index + 1]
238
+ if nodeRecord.getGuiObject() == guiObject then
239
+ return nodeRecord
240
+ end
241
+ end
242
+ return nil
243
+ end
244
+ local function registerImplicitFocusNode(guiObject)
245
+ local existingNode = findExistingFocusNodeByGuiObject(guiObject)
246
+ if existingNode then
247
+ return existingNode
248
+ end
249
+ nextFocusNodeId += 1
250
+ nextFocusOrder += 1
251
+ local nodeRecord = {
252
+ id = nextFocusNodeId,
253
+ scopeId = nil,
254
+ implicit = true,
255
+ order = nextFocusOrder,
256
+ getGuiObject = function()
257
+ return guiObject
258
+ end,
259
+ getDisabled = function()
260
+ return false
261
+ end,
262
+ getVisible = function()
263
+ return nil
264
+ end,
265
+ getSyncToRoblox = function()
266
+ return true
267
+ end,
268
+ }
269
+ table.insert(focusNodes, nodeRecord)
270
+ return nodeRecord
271
+ end
272
+ local function resolveFocusNodeByGuiObject(guiObject, options)
273
+ if not isLiveGuiObject(guiObject) then
274
+ return nil
275
+ end
276
+ local existingNode = findExistingFocusNodeByGuiObject(guiObject)
277
+ if existingNode then
278
+ return getResolvedFocusNode(existingNode.id, options)
279
+ end
280
+ local _result = options
281
+ if _result ~= nil then
282
+ _result = _result.allowImplicit
283
+ end
284
+ local _condition = not _result
285
+ if not _condition then
286
+ _condition = not isRawGuiObjectFocusable(guiObject)
287
+ end
288
+ if _condition then
289
+ return nil
290
+ end
291
+ local implicitNode = registerImplicitFocusNode(guiObject)
292
+ return getResolvedFocusNode(implicitNode.id, options)
293
+ end
294
+ local function findFirstRegisteredNodeInScope(scopeRecord)
295
+ for _, nodeRecord in focusNodes do
296
+ local guiObject = nodeRecord.getGuiObject()
297
+ if not isInsideRoot(scopeRecord.getRoot(), guiObject) then
298
+ continue
299
+ end
300
+ local resolvedNode = getResolvedFocusNode(nodeRecord.id, {
301
+ trapScopeOverride = scopeRecord,
302
+ })
303
+ if resolvedNode then
304
+ return resolvedNode
305
+ end
306
+ end
307
+ return nil
308
+ end
309
+ local function findFirstFocusableDescendantInScope(scopeRecord)
310
+ local scopeRoot = scopeRecord.getRoot()
311
+ if not isLiveGuiObject(scopeRoot) then
312
+ return nil
313
+ end
314
+ local rootNode = resolveFocusNodeByGuiObject(scopeRoot, {
315
+ allowImplicit = true,
316
+ trapScopeOverride = scopeRecord,
317
+ })
318
+ if rootNode then
319
+ return rootNode
320
+ end
321
+ for _, descendant in scopeRoot:GetDescendants() do
322
+ if not descendant:IsA("GuiObject") then
323
+ continue
324
+ end
325
+ local resolvedNode = resolveFocusNodeByGuiObject(descendant, {
326
+ allowImplicit = true,
327
+ trapScopeOverride = scopeRecord,
328
+ })
329
+ if resolvedNode then
330
+ return resolvedNode
331
+ end
332
+ end
333
+ return nil
334
+ end
335
+ local function getBestScopeFallbackNode(scopeRecord)
336
+ local lastFocusedNodeId = scopeRecord.lastFocusedNodeId
337
+ if lastFocusedNodeId ~= nil then
338
+ local lastFocusedNode = getResolvedFocusNode(lastFocusedNodeId, {
339
+ trapScopeOverride = scopeRecord,
340
+ })
341
+ if lastFocusedNode and isInsideRoot(scopeRecord.getRoot(), lastFocusedNode.guiObject) then
342
+ return lastFocusedNode
343
+ end
344
+ end
345
+ return findFirstRegisteredNodeInScope(scopeRecord) or findFirstFocusableDescendantInScope(scopeRecord)
346
+ end
347
+ local function enforceTrappedFocus(excludingScopeId)
348
+ local trappedScope = getTopTrappedScope(excludingScopeId)
349
+ if not trappedScope then
350
+ syncRobloxSelection()
351
+ return nil
352
+ end
353
+ local currentFocusedNode = if currentFocusedNodeId ~= nil then getResolvedFocusNode(currentFocusedNodeId, {
354
+ trapScopeOverride = trappedScope,
355
+ }) else nil
356
+ if currentFocusedNode then
357
+ syncRobloxSelection()
358
+ return nil
359
+ end
360
+ local fallbackNode = getBestScopeFallbackNode(trappedScope)
361
+ if fallbackNode then
362
+ setCurrentFocusedNode(fallbackNode.record.id)
363
+ return nil
364
+ end
365
+ setCurrentFocusedNode(nil)
366
+ end
367
+ local function isFocusNodeReferenced(nodeId)
368
+ if currentFocusedNodeId == nodeId then
369
+ return true
370
+ end
371
+ for _, scopeRecord in focusScopes do
372
+ local _condition = scopeRecord.lastFocusedNodeId == nodeId
373
+ if not _condition then
374
+ local _result = scopeRecord.restoreSnapshot
375
+ if _result ~= nil then
376
+ _result = _result.nodeId
377
+ end
378
+ _condition = _result == nodeId
379
+ end
380
+ if _condition then
381
+ return true
382
+ end
383
+ end
384
+ return false
385
+ end
386
+ local function pruneImplicitFocusNodes()
387
+ for index = #focusNodes - 1, 0, -1 do
388
+ local nodeRecord = focusNodes[index + 1]
389
+ if not nodeRecord.implicit then
390
+ continue
391
+ end
392
+ if isLiveGuiObject(nodeRecord.getGuiObject()) or isFocusNodeReferenced(nodeRecord.id) then
393
+ continue
394
+ end
395
+ local _index = index
396
+ table.remove(focusNodes, _index + 1)
397
+ end
398
+ end
399
+ local function findBridgeRestoreNode()
400
+ local selectedObject = GuiService.SelectedObject
401
+ return resolveFocusNodeByGuiObject(selectedObject, {
402
+ allowImplicit = true,
403
+ })
404
+ end
405
+ local getFocusedNode
406
+ local function getCurrentFocusGuiObject()
407
+ local focusedNode = getFocusedNode()
408
+ local _result = focusedNode
409
+ if _result ~= nil then
410
+ _result = _result.getGuiObject()
411
+ end
412
+ local _condition = _result
413
+ if _condition == nil then
414
+ _condition = GuiService.SelectedObject
415
+ end
416
+ return _condition
417
+ end
418
+ local focusNode, focusGuiObject
419
+ local function restoreScopeFocus(scopeRecord)
420
+ local _snapshotNodeId = scopeRecord.restoreSnapshot
421
+ if _snapshotNodeId ~= nil then
422
+ _snapshotNodeId = _snapshotNodeId.nodeId
423
+ end
424
+ local snapshotNodeId = _snapshotNodeId
425
+ local restoreGuiObject = scopeRecord.restoreGuiObject
426
+ scopeRecord.restoreSnapshot = nil
427
+ scopeRecord.restoreGuiObject = nil
428
+ if snapshotNodeId ~= nil then
429
+ local restoredGuiObject = focusNode(snapshotNodeId)
430
+ if restoredGuiObject then
431
+ return restoredGuiObject
432
+ end
433
+ end
434
+ if restoreGuiObject then
435
+ local restoredGuiObject = focusGuiObject(restoreGuiObject)
436
+ if restoredGuiObject then
437
+ return restoredGuiObject
438
+ end
439
+ end
440
+ local ancestorScope = getFocusScopeRecord(scopeRecord.parentScopeId)
441
+ while ancestorScope do
442
+ local fallbackNode = getBestScopeFallbackNode(ancestorScope)
443
+ if fallbackNode then
444
+ local restoredGuiObject = focusNode(fallbackNode.record.id)
445
+ if restoredGuiObject then
446
+ return restoredGuiObject
447
+ end
448
+ end
449
+ ancestorScope = getFocusScopeRecord(ancestorScope.parentScopeId)
450
+ end
451
+ local topTrappedScope = getTopTrappedScope(scopeRecord.id)
452
+ if topTrappedScope then
453
+ local fallbackNode = getBestScopeFallbackNode(topTrappedScope)
454
+ if fallbackNode then
455
+ local restoredGuiObject = focusNode(fallbackNode.record.id)
456
+ if restoredGuiObject then
457
+ return restoredGuiObject
458
+ end
459
+ end
460
+ end
461
+ setCurrentFocusedNode(nil)
462
+ return nil
463
+ end
464
+ local function handleExternalSelectedObjectChange()
465
+ if bridgeWriteDepth > 0 then
466
+ return nil
467
+ end
468
+ local selectedObject = GuiService.SelectedObject
469
+ local resolvedNode = resolveFocusNodeByGuiObject(selectedObject, {
470
+ allowImplicit = true,
471
+ })
472
+ if resolvedNode then
473
+ setCurrentFocusedNode(resolvedNode.record.id)
474
+ return nil
475
+ end
476
+ enforceTrappedFocus()
477
+ end
478
+ local function startExternalSelectionListener()
479
+ if selectedObjectConnection then
480
+ return nil
481
+ end
482
+ selectedObjectConnection = GuiService:GetPropertyChangedSignal("SelectedObject"):Connect(function()
483
+ handleExternalSelectedObjectChange()
484
+ end)
485
+ end
486
+ local function stopExternalSelectionListener()
487
+ if not selectedObjectConnection then
488
+ return nil
489
+ end
490
+ selectedObjectConnection:Disconnect()
491
+ selectedObjectConnection = nil
492
+ end
493
+ local function syncExternalSelectionListener()
494
+ if externalSelectionConsumerCount > 0 then
495
+ startExternalSelectionListener()
496
+ else
497
+ stopExternalSelectionListener()
498
+ end
499
+ end
500
+ local function registerFocusNode(params)
501
+ nextFocusNodeId += 1
502
+ nextFocusOrder += 1
503
+ local nodeRecord = {
504
+ id = nextFocusNodeId,
505
+ scopeId = params.scopeId,
506
+ implicit = false,
507
+ order = nextFocusOrder,
508
+ getGuiObject = params.getGuiObject,
509
+ getDisabled = params.getDisabled or (function()
510
+ return false
511
+ end),
512
+ getVisible = params.getVisible or (function()
513
+ return nil
514
+ end),
515
+ getSyncToRoblox = params.getSyncToRoblox or (function()
516
+ return true
517
+ end),
518
+ }
519
+ table.insert(focusNodes, nodeRecord)
520
+ local guiObject = nodeRecord.getGuiObject()
521
+ local currentFocusedNode = if currentFocusedNodeId ~= nil then getFocusNodeRecord(currentFocusedNodeId) else nil
522
+ local _result = currentFocusedNode
523
+ if _result ~= nil then
524
+ _result = _result.implicit
525
+ end
526
+ local _condition = _result
527
+ if _condition then
528
+ _condition = currentFocusedNode.getGuiObject() == guiObject
529
+ end
530
+ if _condition then
531
+ currentFocusedNodeId = nodeRecord.id
532
+ for _, scopeRecord in focusScopes do
533
+ if scopeRecord.lastFocusedNodeId == currentFocusedNode.id then
534
+ scopeRecord.lastFocusedNodeId = nodeRecord.id
535
+ end
536
+ end
537
+ end
538
+ enforceTrappedFocus()
539
+ return nodeRecord.id
540
+ end
541
+ local function createFocusScopeId()
542
+ nextFocusScopeId += 1
543
+ return nextFocusScopeId
544
+ end
545
+ local function unregisterFocusNode(nodeId)
546
+ local nodeIndex = findFocusNodeIndex(nodeId)
547
+ if nodeIndex < 0 then
548
+ return nil
549
+ end
550
+ table.remove(focusNodes, nodeIndex + 1)
551
+ for _, scopeRecord in focusScopes do
552
+ if scopeRecord.lastFocusedNodeId == nodeId then
553
+ scopeRecord.lastFocusedNodeId = nil
554
+ end
555
+ end
556
+ if currentFocusedNodeId == nodeId then
557
+ currentFocusedNodeId = nil
558
+ end
559
+ pruneImplicitFocusNodes()
560
+ enforceTrappedFocus()
561
+ end
562
+ local syncFocusScope
563
+ local function registerFocusScope(scopeId, params)
564
+ nextFocusOrder += 1
565
+ local scopeRecord = {
566
+ id = scopeId,
567
+ parentScopeId = params.parentScopeId,
568
+ order = nextFocusOrder,
569
+ wasActive = false,
570
+ getRoot = params.getRoot,
571
+ getActive = params.getActive,
572
+ getTrapped = params.getTrapped,
573
+ getRestoreFocus = params.getRestoreFocus or (function()
574
+ return true
575
+ end),
576
+ getLayerOrder = params.getLayerOrder or (function()
577
+ return nil
578
+ end),
579
+ }
580
+ table.insert(focusScopes, scopeRecord)
581
+ syncFocusScope(scopeRecord.id)
582
+ return scopeRecord.id
583
+ end
584
+ local captureRestoreSnapshot
585
+ function syncFocusScope(scopeId)
586
+ local scopeRecord = getFocusScopeRecord(scopeId)
587
+ if not scopeRecord then
588
+ return nil
589
+ end
590
+ local nextActive = scopeRecord.getActive()
591
+ if nextActive and not scopeRecord.wasActive then
592
+ scopeRecord.restoreSnapshot = if scopeRecord.getRestoreFocus() then captureRestoreSnapshot() else nil
593
+ scopeRecord.restoreGuiObject = if scopeRecord.getRestoreFocus() then getCurrentFocusGuiObject() else nil
594
+ scopeRecord.wasActive = true
595
+ if scopeRecord.getTrapped() then
596
+ enforceTrappedFocus()
597
+ end
598
+ elseif not nextActive and scopeRecord.wasActive then
599
+ scopeRecord.wasActive = false
600
+ local scopeIndex = findFocusScopeIndex(scopeId)
601
+ if scopeIndex >= 0 then
602
+ table.remove(focusScopes, scopeIndex + 1)
603
+ end
604
+ if scopeRecord.getRestoreFocus() then
605
+ restoreScopeFocus(scopeRecord)
606
+ else
607
+ scopeRecord.restoreSnapshot = nil
608
+ scopeRecord.restoreGuiObject = nil
609
+ enforceTrappedFocus(scopeRecord.id)
610
+ end
611
+ table.insert(focusScopes, scopeRecord)
612
+ elseif nextActive and scopeRecord.getTrapped() then
613
+ enforceTrappedFocus()
614
+ end
615
+ pruneImplicitFocusNodes()
616
+ end
617
+ local function unregisterFocusScope(scopeId)
618
+ local scopeIndex = findFocusScopeIndex(scopeId)
619
+ if scopeIndex < 0 then
620
+ return nil
621
+ end
622
+ local scopeRecord = focusScopes[scopeIndex + 1]
623
+ table.remove(focusScopes, scopeIndex + 1)
624
+ if scopeRecord.wasActive and scopeRecord.getRestoreFocus() then
625
+ restoreScopeFocus(scopeRecord)
626
+ else
627
+ enforceTrappedFocus(scopeId)
628
+ end
629
+ pruneImplicitFocusNodes()
630
+ end
631
+ local function retainExternalFocusBridge()
632
+ externalSelectionConsumerCount += 1
633
+ syncExternalSelectionListener()
634
+ end
635
+ local function releaseExternalFocusBridge()
636
+ externalSelectionConsumerCount = math.max(0, externalSelectionConsumerCount - 1)
637
+ syncExternalSelectionListener()
638
+ end
639
+ local function canFocusNode(nodeId)
640
+ return getResolvedFocusNode(nodeId) ~= nil
641
+ end
642
+ function focusNode(nodeId)
643
+ local resolvedNode = getResolvedFocusNode(nodeId)
644
+ if not resolvedNode then
645
+ return nil
646
+ end
647
+ return setCurrentFocusedNode(resolvedNode.record.id)
648
+ end
649
+ function focusGuiObject(guiObject)
650
+ local resolvedNode = resolveFocusNodeByGuiObject(guiObject, {
651
+ allowImplicit = true,
652
+ })
653
+ if not resolvedNode then
654
+ return nil
655
+ end
656
+ return setCurrentFocusedNode(resolvedNode.record.id)
657
+ end
658
+ local function clearFocus()
659
+ setCurrentFocusedNode(nil)
660
+ enforceTrappedFocus()
661
+ end
662
+ function getFocusedNode()
663
+ if currentFocusedNodeId == nil then
664
+ return nil
665
+ end
666
+ local resolvedNode = getResolvedFocusNode(currentFocusedNodeId)
667
+ local _result = resolvedNode
668
+ if _result ~= nil then
669
+ _result = _result.record
670
+ end
671
+ return _result
672
+ end
673
+ local function getFocusedGuiObject()
674
+ local focusedNode = getFocusedNode()
675
+ local _result = focusedNode
676
+ if _result ~= nil then
677
+ _result = _result.getGuiObject()
678
+ end
679
+ return _result
680
+ end
681
+ function captureRestoreSnapshot()
682
+ local focusedNode = getFocusedNode()
683
+ if focusedNode then
684
+ return {
685
+ nodeId = focusedNode.id,
686
+ }
687
+ end
688
+ local bridgeNode = findBridgeRestoreNode()
689
+ local _object = {}
690
+ local _left = "nodeId"
691
+ local _result = bridgeNode
692
+ if _result ~= nil then
693
+ _result = _result.record.id
694
+ end
695
+ _object[_left] = _result
696
+ return _object
697
+ end
698
+ local function restoreSnapshot(snapshot)
699
+ local _snapshotNodeId = snapshot
700
+ if _snapshotNodeId ~= nil then
701
+ _snapshotNodeId = _snapshotNodeId.nodeId
702
+ end
703
+ local snapshotNodeId = _snapshotNodeId
704
+ if snapshotNodeId == nil then
705
+ clearFocus()
706
+ return nil
707
+ end
708
+ return focusNode(snapshotNodeId)
709
+ end
710
+ return {
711
+ registerFocusNode = registerFocusNode,
712
+ createFocusScopeId = createFocusScopeId,
713
+ unregisterFocusNode = unregisterFocusNode,
714
+ registerFocusScope = registerFocusScope,
715
+ syncFocusScope = syncFocusScope,
716
+ unregisterFocusScope = unregisterFocusScope,
717
+ retainExternalFocusBridge = retainExternalFocusBridge,
718
+ releaseExternalFocusBridge = releaseExternalFocusBridge,
719
+ canFocusNode = canFocusNode,
720
+ focusNode = focusNode,
721
+ focusGuiObject = focusGuiObject,
722
+ clearFocus = clearFocus,
723
+ getFocusedNode = getFocusedNode,
724
+ getFocusedGuiObject = getFocusedGuiObject,
725
+ captureRestoreSnapshot = captureRestoreSnapshot,
726
+ restoreSnapshot = restoreSnapshot,
727
+ }
@@ -0,0 +1,10 @@
1
+ import React from "../react";
2
+ export type UseFocusNodeOptions = {
3
+ ref: React.MutableRefObject<GuiObject | undefined>;
4
+ scopeId?: number;
5
+ disabled?: boolean;
6
+ getDisabled?: () => boolean;
7
+ getVisible?: () => boolean | undefined;
8
+ syncToRoblox?: boolean;
9
+ };
10
+ export declare function useFocusNode(options: UseFocusNodeOptions): React.MutableRefObject<number | undefined>;
@@ -0,0 +1,65 @@
1
+ -- Compiled with roblox-ts v3.0.0
2
+ local TS = _G[script]
3
+ local React = TS.import(script, script.Parent.Parent, "react").default
4
+ local useFocusScopeId = TS.import(script, script.Parent, "context").useFocusScopeId
5
+ local _focusManager = TS.import(script, script.Parent, "focusManager")
6
+ local registerFocusNode = _focusManager.registerFocusNode
7
+ local unregisterFocusNode = _focusManager.unregisterFocusNode
8
+ local function useLatest(value)
9
+ local ref = React.useRef(value)
10
+ React.useEffect(function()
11
+ ref.current = value
12
+ end, { value })
13
+ return ref
14
+ end
15
+ local function useFocusNode(options)
16
+ local inheritedScopeId = useFocusScopeId()
17
+ local _condition = options.scopeId
18
+ if _condition == nil then
19
+ _condition = inheritedScopeId
20
+ end
21
+ local scopeId = _condition
22
+ local nodeIdRef = React.useRef()
23
+ local disabledRef = useLatest(options.disabled == true)
24
+ local getDisabledRef = useLatest(options.getDisabled)
25
+ local getVisibleRef = useLatest(options.getVisible)
26
+ local syncToRobloxRef = useLatest(options.syncToRoblox ~= false)
27
+ React.useEffect(function()
28
+ local nodeId = registerFocusNode({
29
+ scopeId = scopeId,
30
+ getGuiObject = function()
31
+ return options.ref.current
32
+ end,
33
+ getDisabled = function()
34
+ local _condition_1 = disabledRef.current
35
+ if not _condition_1 then
36
+ local _result = getDisabledRef.current
37
+ if _result ~= nil then
38
+ _result = _result()
39
+ end
40
+ _condition_1 = _result == true
41
+ end
42
+ return _condition_1
43
+ end,
44
+ getVisible = function()
45
+ local _result = getVisibleRef.current
46
+ if _result ~= nil then
47
+ _result = _result()
48
+ end
49
+ return _result
50
+ end,
51
+ getSyncToRoblox = function()
52
+ return syncToRobloxRef.current
53
+ end,
54
+ })
55
+ nodeIdRef.current = nodeId
56
+ return function()
57
+ unregisterFocusNode(nodeId)
58
+ nodeIdRef.current = nil
59
+ end
60
+ end, { options.ref, scopeId })
61
+ return nodeIdRef
62
+ end
63
+ return {
64
+ useFocusNode = useFocusNode,
65
+ }
package/out/index.d.ts CHANGED
@@ -1,4 +1,8 @@
1
1
  export * from "./context";
2
+ export * from "./focus/context";
3
+ export * from "./focus/focusManager";
4
+ export * from "./focus/useFocusNode";
5
+ export * from "./orderedSelection";
2
6
  export { default as React } from "./react";
3
7
  export { default as ReactRoblox } from "./reactRoblox";
4
8
  export * from "./refs";
package/out/init.luau CHANGED
@@ -4,6 +4,18 @@ local exports = {}
4
4
  for _k, _v in TS.import(script, script, "context") or {} do
5
5
  exports[_k] = _v
6
6
  end
7
+ for _k, _v in TS.import(script, script, "focus", "context") or {} do
8
+ exports[_k] = _v
9
+ end
10
+ for _k, _v in TS.import(script, script, "focus", "focusManager") or {} do
11
+ exports[_k] = _v
12
+ end
13
+ for _k, _v in TS.import(script, script, "focus", "useFocusNode") or {} do
14
+ exports[_k] = _v
15
+ end
16
+ for _k, _v in TS.import(script, script, "orderedSelection") or {} do
17
+ exports[_k] = _v
18
+ end
7
19
  exports.React = TS.import(script, script, "react").default
8
20
  exports.ReactRoblox = TS.import(script, script, "reactRoblox").default
9
21
  for _k, _v in TS.import(script, script, "refs") or {} do
@@ -0,0 +1,16 @@
1
+ import type React from "@rbxts/react";
2
+ export type OrderedSelectionDirection = -1 | 1;
3
+ export type OrderedSelectionEntry = {
4
+ id: number;
5
+ order: number;
6
+ ref: React.MutableRefObject<GuiObject | undefined>;
7
+ getDisabled?: () => boolean;
8
+ getVisible?: () => boolean;
9
+ };
10
+ export declare function getOrderedSelectionEntries<T extends OrderedSelectionEntry>(entries: Array<T>): T[];
11
+ export declare function isOrderedSelectionEntryAvailable(entry: OrderedSelectionEntry): boolean;
12
+ export declare function findOrderedSelectionEntry<T extends OrderedSelectionEntry>(entries: Array<T>, predicate: (entry: T) => boolean): T | undefined;
13
+ export declare function getCurrentOrderedSelectionEntry<T extends OrderedSelectionEntry>(entries: Array<T>): T | undefined;
14
+ export declare function getFirstOrderedSelectionEntry<T extends OrderedSelectionEntry>(entries: Array<T>): T | undefined;
15
+ export declare function getRelativeOrderedSelectionEntry<T extends OrderedSelectionEntry>(entries: Array<T>, currentId: number | undefined, direction: OrderedSelectionDirection): T | undefined;
16
+ export declare function focusOrderedSelectionEntry(entry: OrderedSelectionEntry | undefined): GuiObject | undefined;
@@ -0,0 +1,148 @@
1
+ -- Compiled with roblox-ts v3.0.0
2
+ local TS = _G[script]
3
+ local _focusManager = TS.import(script, script.Parent, "focus", "focusManager")
4
+ local focusManagedGuiObject = _focusManager.focusGuiObject
5
+ local getFocusedGuiObject = _focusManager.getFocusedGuiObject
6
+ local function isEntryVisible(entry)
7
+ local target = entry.ref.current
8
+ if not target then
9
+ return false
10
+ end
11
+ if entry.getVisible and not entry.getVisible() then
12
+ return false
13
+ end
14
+ return target.Visible
15
+ end
16
+ local function getOrderedSelectionEntries(entries)
17
+ local _array = {}
18
+ local _length = #_array
19
+ table.move(entries, 1, #entries, _length + 1, _array)
20
+ local ordered = _array
21
+ table.sort(ordered, function(left, right)
22
+ return left.order < right.order
23
+ end)
24
+ return ordered
25
+ end
26
+ local function isOrderedSelectionEntryAvailable(entry)
27
+ local target = entry.ref.current
28
+ if not target then
29
+ return false
30
+ end
31
+ local _result = entry.getDisabled
32
+ if _result ~= nil then
33
+ _result = _result()
34
+ end
35
+ if _result == true then
36
+ return false
37
+ end
38
+ if not isEntryVisible(entry) then
39
+ return false
40
+ end
41
+ return target.Selectable
42
+ end
43
+ local function findOrderedSelectionEntry(entries, predicate)
44
+ local _exp = getOrderedSelectionEntries(entries)
45
+ -- ▼ ReadonlyArray.find ▼
46
+ local _callback = function(entry)
47
+ return predicate(entry) and isOrderedSelectionEntryAvailable(entry)
48
+ end
49
+ local _result
50
+ for _i, _v in _exp do
51
+ if _callback(_v, _i - 1, _exp) == true then
52
+ _result = _v
53
+ break
54
+ end
55
+ end
56
+ -- ▲ ReadonlyArray.find ▲
57
+ return _result
58
+ end
59
+ local function getCurrentOrderedSelectionEntry(entries)
60
+ local current = getFocusedGuiObject()
61
+ if not current then
62
+ return nil
63
+ end
64
+ local _exp = getOrderedSelectionEntries(entries)
65
+ -- ▼ ReadonlyArray.find ▼
66
+ local _callback = function(entry)
67
+ return entry.ref.current == current and isOrderedSelectionEntryAvailable(entry)
68
+ end
69
+ local _result
70
+ for _i, _v in _exp do
71
+ if _callback(_v, _i - 1, _exp) == true then
72
+ _result = _v
73
+ break
74
+ end
75
+ end
76
+ -- ▲ ReadonlyArray.find ▲
77
+ return _result
78
+ end
79
+ local function getFirstOrderedSelectionEntry(entries)
80
+ local _exp = getOrderedSelectionEntries(entries)
81
+ -- ▼ ReadonlyArray.find ▼
82
+ local _result
83
+ for _i, _v in _exp do
84
+ if isOrderedSelectionEntryAvailable(_v, _i - 1, _exp) == true then
85
+ _result = _v
86
+ break
87
+ end
88
+ end
89
+ -- ▲ ReadonlyArray.find ▲
90
+ return _result
91
+ end
92
+ local function getRelativeOrderedSelectionEntry(entries, currentId, direction)
93
+ local _exp = getOrderedSelectionEntries(entries)
94
+ -- ▼ ReadonlyArray.filter ▼
95
+ local _newValue = {}
96
+ local _length = 0
97
+ for _k, _v in _exp do
98
+ if isOrderedSelectionEntryAvailable(_v, _k - 1, _exp) == true then
99
+ _length += 1
100
+ _newValue[_length] = _v
101
+ end
102
+ end
103
+ -- ▲ ReadonlyArray.filter ▲
104
+ local selectableEntries = _newValue
105
+ if #selectableEntries == 0 then
106
+ return nil
107
+ end
108
+ local _result
109
+ if currentId ~= nil then
110
+ -- ▼ ReadonlyArray.findIndex ▼
111
+ local _callback = function(entry)
112
+ return entry.id == currentId
113
+ end
114
+ local _result_1 = -1
115
+ for _i, _v in selectableEntries do
116
+ if _callback(_v, _i - 1, selectableEntries) == true then
117
+ _result_1 = _i - 1
118
+ break
119
+ end
120
+ end
121
+ -- ▲ ReadonlyArray.findIndex ▲
122
+ _result = _result_1
123
+ else
124
+ _result = -1
125
+ end
126
+ local currentIndex = _result
127
+ if currentIndex == -1 then
128
+ return if direction > 0 then selectableEntries[1] else selectableEntries[#selectableEntries]
129
+ end
130
+ local nextIndex = math.clamp(currentIndex + direction, 0, #selectableEntries - 1)
131
+ return selectableEntries[nextIndex + 1]
132
+ end
133
+ local function focusOrderedSelectionEntry(entry)
134
+ local _result = entry
135
+ if _result ~= nil then
136
+ _result = _result.ref.current
137
+ end
138
+ return focusManagedGuiObject(_result)
139
+ end
140
+ return {
141
+ getOrderedSelectionEntries = getOrderedSelectionEntries,
142
+ isOrderedSelectionEntryAvailable = isOrderedSelectionEntryAvailable,
143
+ findOrderedSelectionEntry = findOrderedSelectionEntry,
144
+ getCurrentOrderedSelectionEntry = getCurrentOrderedSelectionEntry,
145
+ getFirstOrderedSelectionEntry = getFirstOrderedSelectionEntry,
146
+ getRelativeOrderedSelectionEntry = getRelativeOrderedSelectionEntry,
147
+ focusOrderedSelectionEntry = focusOrderedSelectionEntry,
148
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lattice-ui/core",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "private": false,
5
5
  "main": "out/init.luau",
6
6
  "types": "out/index.d.ts",