@nrbx/topbar-components 1.1.14 → 1.2.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.
package/README.md CHANGED
@@ -55,32 +55,30 @@ Instantiate `<TopbarProvider />` to be a root of your topbar component tree.
55
55
  </TopbarProvider>
56
56
  ```
57
57
 
58
- #### Positioning icons with dock containers
58
+ Icons without a `position` prop default to the **left** dock.
59
59
 
60
- Icons placed directly inside `<TopbarProvider>` default to the left side. Use the dock container components to position icons at the center or right of the bar:
60
+ #### Positioning icons with the `position` prop
61
+
62
+ Use the `position` prop on `<Icon>` components to automatically route them into the left, centre, or right dock:
61
63
 
62
64
  ```tsx
63
65
  <TopbarProvider>
64
- <LeftDock>
65
- <Icon text="Home" />
66
- <Icon text="Settings" />
67
- </LeftDock>
68
- <CenterDock>
69
- <Icon text="Server Time" static />
70
- </CenterDock>
71
- <RightDock>
72
- <Icon text="Profile" imageId="rbxassetid://..." />
73
- </RightDock>
66
+ <Icon position="left" text="Home" />
67
+ <Icon position="left" text="Settings" />
68
+ <Icon position="centre" text="Server Time" static />
69
+ <Icon position="right" text="Profile" imageId="rbxassetid://..." />
74
70
  </TopbarProvider>
75
71
  ```
76
72
 
77
- | Container | Anchor | Purpose |
78
- |---|---|---|
79
- | `<LeftDock>` | left edge | Default - icons flow left-to-right from the left |
80
- | `<CenterDock>` | center (50%) | Centers icons in the bar |
81
- | `<RightDock>` | right edge | Right-aligns icons |
73
+ All three docks render automatically no manual dock wrappers needed.
82
74
 
83
- Each dock container renders its own horizontal list with the configured `iconSpacing` and vertical centering. Center and right docks automatically apply `iconGroupSpacing` for visual separation.
75
+ | Position | Behaviour |
76
+ |---|---|
77
+ | `"left"` (default) | Icons flow left-to-right from the left edge |
78
+ | `"centre"` | Centers icons in the bar |
79
+ | `"right"` | Right-aligns icons |
80
+
81
+ Dock containers (`<LeftDock>`, `<CenterDock>`, `<RightDock>`) are still available for advanced layouts requiring nested structure.
84
82
 
85
83
  #### Icon props: `static` and `disabled`
86
84
 
@@ -192,25 +190,37 @@ Use `<CustomDropdown>` when you need to show rich, arbitrary content in a dropdo
192
190
 
193
191
  Appearance is controlled via `stylesheet.customDropdown`.
194
192
 
195
- #### Overflow handler
193
+ #### Automatic overflow
194
+
195
+ `TopbarProvider` accepts an `overflowAmount` prop that caps the visible icons per dock:
196
+
197
+ ```tsx
198
+ <TopbarProvider overflowAmount={4}>
199
+ <Icon text="Home" />
200
+ <Icon text="Settings" />
201
+ <Icon text="Profile" />
202
+ <Icon text="Messages" />
203
+ <Icon text="Help" />
204
+ {/* Icons beyond 4 in any dock collapse into a "⋯" overflow menu */}
205
+ </TopbarProvider>
206
+ ```
196
207
 
197
- When you have many icons in a dock, wrap them in `<Overflow>` to automatically collapse extras into a "more" (⋯) dropdown:
208
+ When `overflowAmount` is **omitted**, the provider automatically detects whether icons from different docks overlap and progressively overflows them to prevent visual collisions. Icons moved into overflow appear in a dropdown behind the "" button.
209
+
210
+ #### Manual overflow
211
+
212
+ For advanced cases, you can still wrap icons directly in `<Overflow>` inside a dock container:
198
213
 
199
214
  ```tsx
200
215
  <LeftDock>
201
216
  <Overflow>
202
217
  <Icon text="Home" />
203
218
  <Icon text="Settings" />
204
- <Icon text="Profile" />
205
- <Icon text="Messages" />
206
- <Icon text="Help" />
207
219
  {/* ... many more ... */}
208
220
  </Overflow>
209
221
  </LeftDock>
210
222
  ```
211
223
 
212
- Based on TopbarPlus v3's overflow handler.
213
-
214
224
  ### Stylesheets
215
225
 
216
226
  You can use stylesheets to override default properties of all components within.
@@ -91,6 +91,12 @@ export interface IconProps extends React.PropsWithChildren {
91
91
  onRightClick?: () => void;
92
92
  /** Function used to play click sounds. Receives the sound asset ID. */
93
93
  playSound?: (id: string) => void;
94
+ /**
95
+ * When placed as a direct child of `TopbarProvider`, this controls
96
+ * which dock the icon is automatically routed to.
97
+ * Defaults to `"left"`.
98
+ */
99
+ position?: 'left' | 'right' | 'centre';
94
100
  }
95
101
  /** Icon select/deselect state. */
96
102
  export type IconState = 'selected' | 'deselected';
@@ -24,18 +24,17 @@ local function Overflow(_param)
24
24
  return arr
25
25
  end, { children })
26
26
  local overflowCount = #childArray
27
- local handleToggle = useCallback(function()
28
- setExpanded(function(prev)
29
- return not prev
30
- end)
27
+ local handleToggle = useCallback(function(state)
28
+ setExpanded(state == "selected")
31
29
  end, {})
32
30
  if overflowCount == 0 then
33
31
  return React.createElement(React.Fragment)
34
32
  end
35
33
  return React.createElement(Icon, {
36
34
  imageId = "rbxassetid://6069276526",
37
- static = false,
38
35
  toggleStateOnClick = true,
36
+ defaultState = "deselected",
37
+ stateChanged = handleToggle,
39
38
  contentPaddingX = 4,
40
39
  contentPaddingY = 4,
41
40
  }, expanded and (React.createElement("frame", {
@@ -1,11 +1,17 @@
1
1
  import React from '@rbxts/react';
2
2
  export type SelectionMode = 'Single' | 'Multiple';
3
3
  /** Docks icons to the left side of the topbar. */
4
- export declare function LeftDock({ children }: React.PropsWithChildren): React.JSX.Element;
4
+ export declare function LeftDock({ children, frameRef }: React.PropsWithChildren & {
5
+ frameRef?: React.Ref<Frame>;
6
+ }): React.JSX.Element;
5
7
  /** Docks icons to the center of the topbar. */
6
- export declare function CenterDock({ children }: React.PropsWithChildren): React.JSX.Element;
8
+ export declare function CenterDock({ children, frameRef }: React.PropsWithChildren & {
9
+ frameRef?: React.Ref<Frame>;
10
+ }): React.JSX.Element;
7
11
  /** Docks icons to the right side of the topbar. */
8
- export declare function RightDock({ children }: React.PropsWithChildren): React.JSX.Element;
12
+ export declare function RightDock({ children, frameRef }: React.PropsWithChildren & {
13
+ frameRef?: React.Ref<Frame>;
14
+ }): React.JSX.Element;
9
15
  interface ProviderProps extends React.PropsWithChildren {
10
16
  /**
11
17
  * How icon selection works across the topbar.
@@ -15,12 +21,23 @@ interface ProviderProps extends React.PropsWithChildren {
15
21
  selectionMode?: SelectionMode;
16
22
  /** When `true`, shows a "Beta" label if voice chat is enabled on the client. */
17
23
  gameVoiceChatEnabled?: boolean;
24
+ /**
25
+ * Maximum number of icons shown per dock before overflow kicks in.
26
+ * When set, icons beyond this limit are moved into an overflow dropdown.
27
+ * When `undefined`, auto-detects overflow by measuring dock sizes to
28
+ * prevent icons from different docks overlapping each other.
29
+ */
30
+ overflowAmount?: number;
18
31
  }
19
32
  /**
20
33
  * Root provider for the topbar system.
21
34
  *
22
35
  * Must wrap all topbar components. Manages global selection state,
23
36
  * gui inset tracking, and the topbar frame bounds.
37
+ *
38
+ * Icons with a `position` prop placed as direct children are
39
+ * automatically routed into the left, centre, or right dock.
40
+ * Icons without a `position` prop are rendered as-is.
24
41
  */
25
- export declare function TopbarProvider({ selectionMode, gameVoiceChatEnabled, children }: ProviderProps): React.JSX.Element;
42
+ export declare function TopbarProvider({ selectionMode, gameVoiceChatEnabled, overflowAmount, children, }: ProviderProps): React.JSX.Element;
26
43
  export {};
@@ -2,6 +2,9 @@
2
2
  local TS = _G[script]
3
3
  local _react = TS.import(script, TS.getModule(script, "@rbxts", "react"))
4
4
  local React = _react
5
+ local useEffect = _react.useEffect
6
+ local useMemo = _react.useMemo
7
+ local useRef = _react.useRef
5
8
  local useState = _react.useState
6
9
  local _context = TS.import(script, script.Parent.Parent, "context")
7
10
  local LocationContext = _context.LocationContext
@@ -9,6 +12,7 @@ local useStylesheet = _context.useStylesheet
9
12
  local useGuiInset = TS.import(script, script.Parent.Parent, "hooks", "use-gui-inset").useGuiInset
10
13
  local useVoicechatEnabled = TS.import(script, script.Parent.Parent, "hooks", "use-voicechat-enabled").useVoicechatEnabled
11
14
  local debugLog = TS.import(script, script.Parent.Parent, "utilities", "debug").debugLog
15
+ local Overflow = TS.import(script, script.Parent, "overflow").Overflow
12
16
  --[[
13
17
  *
14
18
  * Anchors children to a side of the topbar.
@@ -18,6 +22,7 @@ local debugLog = TS.import(script, script.Parent.Parent, "utilities", "debug").d
18
22
  local function DockFrame(props)
19
23
  local stylesheet = useStylesheet().provider
20
24
  return React.createElement("frame", {
25
+ ref = props.frameRef,
21
26
  BackgroundTransparency = 1,
22
27
  AnchorPoint = props.anchor,
23
28
  Position = props.position,
@@ -36,7 +41,9 @@ end
36
41
  --* Docks icons to the left side of the topbar.
37
42
  local function LeftDock(_param)
38
43
  local children = _param.children
44
+ local frameRef = _param.frameRef
39
45
  return React.createElement(DockFrame, {
46
+ frameRef = frameRef,
40
47
  anchor = Vector2.new(0, 0.5),
41
48
  position = UDim2.new(0, 0, 0.5, 0),
42
49
  children = children,
@@ -45,8 +52,10 @@ end
45
52
  --* Docks icons to the center of the topbar.
46
53
  local function CenterDock(_param)
47
54
  local children = _param.children
55
+ local frameRef = _param.frameRef
48
56
  local stylesheet = useStylesheet().provider
49
57
  return React.createElement(DockFrame, {
58
+ frameRef = frameRef,
50
59
  anchor = Vector2.new(0.5, 0.5),
51
60
  position = UDim2.new(0.5, 0, 0.5, 0),
52
61
  paddingLeft = UDim.new(0, stylesheet.iconGroupSpacing),
@@ -57,8 +66,10 @@ end
57
66
  --* Docks icons to the right side of the topbar.
58
67
  local function RightDock(_param)
59
68
  local children = _param.children
69
+ local frameRef = _param.frameRef
60
70
  local stylesheet = useStylesheet().provider
61
71
  return React.createElement(DockFrame, {
72
+ frameRef = frameRef,
62
73
  anchor = Vector2.new(1, 0.5),
63
74
  position = UDim2.new(1, 0, 0.5, 0),
64
75
  paddingLeft = UDim.new(0, stylesheet.iconGroupSpacing),
@@ -71,6 +82,10 @@ end
71
82
  *
72
83
  * Must wrap all topbar components. Manages global selection state,
73
84
  * gui inset tracking, and the topbar frame bounds.
85
+ *
86
+ * Icons with a `position` prop placed as direct children are
87
+ * automatically routed into the left, centre, or right dock.
88
+ * Icons without a `position` prop are rendered as-is.
74
89
 
75
90
  ]]
76
91
  local function TopbarProvider(_param)
@@ -79,11 +94,169 @@ local function TopbarProvider(_param)
79
94
  selectionMode = "Single"
80
95
  end
81
96
  local gameVoiceChatEnabled = _param.gameVoiceChatEnabled
97
+ local overflowAmount = _param.overflowAmount
82
98
  local children = _param.children
83
99
  local selectedIcons, setSelectedIcons = useState({})
84
100
  local inset = useGuiInset()
85
101
  local voiceChatEnabled = useVoicechatEnabled()
86
102
  local stylesheet = useStylesheet().provider
103
+ local containerRef = useRef()
104
+ local leftDockRef = useRef()
105
+ local centerDockRef = useRef()
106
+ local rightDockRef = useRef()
107
+ local autoOverflow, setAutoOverflow = useState({
108
+ left = 0,
109
+ centre = 0,
110
+ right = 0,
111
+ })
112
+ --* Sort direct children into docks based on the `position` prop. Defaults to left.
113
+ local sorted = useMemo(function()
114
+ local groups = {
115
+ left = {},
116
+ centre = {},
117
+ right = {},
118
+ }
119
+ local other = {}
120
+ for _, child in React.Children.toArray(children) do
121
+ if not React.isValidElement(child) then
122
+ continue
123
+ end
124
+ local _condition = (child.props).position
125
+ if _condition == nil then
126
+ _condition = "left"
127
+ end
128
+ local pos = _condition
129
+ if groups[pos] ~= nil then
130
+ local _exp = groups[pos]
131
+ table.insert(_exp, child)
132
+ else
133
+ table.insert(other, child)
134
+ end
135
+ end
136
+ return {
137
+ groups = groups,
138
+ other = other,
139
+ }
140
+ end, { children })
141
+ -- get overflow count for a dock
142
+ local getOverflowCount = function(dock, total)
143
+ if overflowAmount ~= nil then
144
+ return math.max(0, total - overflowAmount)
145
+ end
146
+ return autoOverflow[dock]
147
+ end
148
+ -- auto-detect overflow when overflowAmount is not set
149
+ useEffect(function()
150
+ if overflowAmount ~= nil then
151
+ return nil
152
+ end
153
+ local container = containerRef.current
154
+ if not container then
155
+ return nil
156
+ end
157
+ local check = function()
158
+ local leftFrame = leftDockRef.current
159
+ local centerFrame = centerDockRef.current
160
+ local rightFrame = rightDockRef.current
161
+ if not leftFrame or not centerFrame or not rightFrame then
162
+ return nil
163
+ end
164
+ local containerWidth = container.AbsoluteSize.X
165
+ local groupSpacing = stylesheet.iconGroupSpacing
166
+ local leftRightEdge = leftFrame.AbsolutePosition.X + leftFrame.AbsoluteSize.X
167
+ local centerLeftEdge = centerFrame.AbsolutePosition.X - centerFrame.AbsoluteSize.X / 2
168
+ local centerRightEdge = centerFrame.AbsolutePosition.X + centerFrame.AbsoluteSize.X / 2
169
+ local rightLeftEdge = rightFrame.AbsolutePosition.X - rightFrame.AbsoluteSize.X
170
+ local overlapThreshold = groupSpacing + 10
171
+ local leftOverflow = 0
172
+ local centreOverflow = 0
173
+ local rightOverflow = 0
174
+ if leftRightEdge + overlapThreshold > centerLeftEdge then
175
+ local overflowPixels = leftRightEdge + overlapThreshold - centerLeftEdge
176
+ local iconEstimate = 52
177
+ leftOverflow = math.max(1, math.ceil(overflowPixels / iconEstimate))
178
+ end
179
+ if centerRightEdge + overlapThreshold > rightLeftEdge then
180
+ local overflowPixels = centerRightEdge + overlapThreshold - rightLeftEdge
181
+ local iconEstimate = 52
182
+ local totalOverflow = math.max(1, math.ceil(overflowPixels / iconEstimate))
183
+ centreOverflow = math.ceil(totalOverflow / 2)
184
+ rightOverflow = totalOverflow - centreOverflow
185
+ end
186
+ if leftRightEdge > containerWidth - 10 then
187
+ local overflowPixels = leftRightEdge - containerWidth + 10
188
+ leftOverflow = math.max(leftOverflow, math.ceil(overflowPixels / 52))
189
+ end
190
+ if rightFrame.AbsolutePosition.X < 10 then
191
+ rightOverflow = math.max(rightOverflow, 1)
192
+ end
193
+ setAutoOverflow(function(prev)
194
+ if prev.left == leftOverflow and prev.centre == centreOverflow and prev.right == rightOverflow then
195
+ return prev
196
+ end
197
+ return {
198
+ left = leftOverflow,
199
+ centre = centreOverflow,
200
+ right = rightOverflow,
201
+ }
202
+ end)
203
+ end
204
+ -- Defer measurement to let layout complete
205
+ task.spawn(function()
206
+ return check()
207
+ end)
208
+ local connections = {}
209
+ for _, frame in { leftDockRef.current, centerDockRef.current, rightDockRef.current } do
210
+ if frame then
211
+ local _arg0 = frame:GetPropertyChangedSignal("AbsoluteSize"):Connect(function()
212
+ return task.spawn(function()
213
+ return check()
214
+ end)
215
+ end)
216
+ local _arg1 = frame:GetPropertyChangedSignal("AbsolutePosition"):Connect(function()
217
+ return task.spawn(function()
218
+ return check()
219
+ end)
220
+ end)
221
+ -- ▼ Array.push ▼
222
+ table.insert(connections, _arg0)
223
+ table.insert(connections, _arg1)
224
+ -- ▲ Array.push ▲
225
+ end
226
+ end
227
+ return function()
228
+ -- ▼ ReadonlyArray.forEach ▼
229
+ local _callback = function(c)
230
+ c:Disconnect()
231
+ end
232
+ for _k, _v in connections do
233
+ _callback(_v, _k - 1, connections)
234
+ end
235
+ -- ▲ ReadonlyArray.forEach ▲
236
+ return nil
237
+ end
238
+ end, { sorted, overflowAmount, stylesheet.iconGroupSpacing })
239
+ -- build dock content with optional overflow
240
+ local buildDockContent = function(icons, dock)
241
+ local total = #icons
242
+ local overflowCount = getOverflowCount(dock, total)
243
+ if overflowCount <= 0 then
244
+ return icons
245
+ end
246
+ local visibleCount = total - overflowCount
247
+ local visible = {}
248
+ local overflowed = {}
249
+ for i = 1, total do
250
+ if i <= visibleCount then
251
+ local _arg0 = icons[i]
252
+ table.insert(visible, _arg0)
253
+ else
254
+ local _arg0 = icons[i]
255
+ table.insert(overflowed, _arg0)
256
+ end
257
+ end
258
+ return React.createElement(React.createFragment, nil, visible, React.createElement(Overflow, nil, overflowed))
259
+ end
87
260
  local hasBetaLabel = gameVoiceChatEnabled and voiceChatEnabled
88
261
  local leftPadding = if hasBetaLabel then stylesheet.paddingLeft + 16 else stylesheet.paddingLeft
89
262
  local rawHeight = inset.Height - stylesheet.insetHeightOffset
@@ -135,6 +308,7 @@ local function TopbarProvider(_param)
135
308
  end,
136
309
  },
137
310
  }, React.createElement("frame", {
311
+ ref = containerRef,
138
312
  BackgroundTransparency = stylesheet.backgroundTransparency,
139
313
  BackgroundColor3 = stylesheet.backgroundColor,
140
314
  Size = UDim2.fromOffset(inset.Width * stylesheet.sizeScale.X, frameHeight * stylesheet.sizeScale.Y),
@@ -145,7 +319,13 @@ local function TopbarProvider(_param)
145
319
  PaddingRight = UDim.new(0, stylesheet.paddingRight),
146
320
  PaddingTop = UDim.new(0, stylesheet.paddingTop),
147
321
  PaddingBottom = UDim.new(0, stylesheet.paddingBottom),
148
- }), children))
322
+ }), React.createElement(LeftDock, {
323
+ frameRef = leftDockRef,
324
+ }, buildDockContent(sorted.groups.left, "left")), React.createElement(CenterDock, {
325
+ frameRef = centerDockRef,
326
+ }, buildDockContent(sorted.groups.centre, "centre")), React.createElement(RightDock, {
327
+ frameRef = rightDockRef,
328
+ }, buildDockContent(sorted.groups.right, "right")), sorted.other))
149
329
  end
150
330
  return {
151
331
  LeftDock = LeftDock,
package/out/index.d.ts CHANGED
@@ -1,9 +1,8 @@
1
- export * from "./components/custom-dropdown";
2
- export * from "./components/dropdown";
3
- export * from "./components/icon";
4
- export * from "./components/notification";
5
- export * from "./components/overflow";
6
- export * from "./components/provider";
7
- export * from "./components/stylesheet";
8
- export * from "./components/tooltip";
9
- export * from "./hooks/use-toggle-key";
1
+ export * from './components/custom-dropdown';
2
+ export * from './components/dropdown';
3
+ export * from './components/icon';
4
+ export * from './components/overflow';
5
+ export * from './components/provider';
6
+ export * from './components/stylesheet';
7
+ export * from './components/tooltip';
8
+ export * from './hooks/use-toggle-key';
package/out/init.luau CHANGED
@@ -10,9 +10,6 @@ end
10
10
  for _k, _v in TS.import(script, script, "components", "icon") or {} do
11
11
  exports[_k] = _v
12
12
  end
13
- for _k, _v in TS.import(script, script, "components", "notification") or {} do
14
- exports[_k] = _v
15
- end
16
13
  for _k, _v in TS.import(script, script, "components", "overflow") or {} do
17
14
  exports[_k] = _v
18
15
  end
package/out/style.luau CHANGED
@@ -66,6 +66,7 @@ local DefaultStylesheet = {
66
66
  hover = noop,
67
67
  unhover = noop,
68
68
  children = {},
69
+ position = "left",
69
70
  },
70
71
  dropdown = {
71
72
  maxWidth = 300,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nrbx/topbar-components",
3
- "version": "1.1.14",
3
+ "version": "1.2.0",
4
4
  "main": "out/init.luau",
5
5
  "scripts": {
6
6
  "build": "rbxtsc",