@drakkar.software/octospaces-ui 0.4.2 → 0.4.3

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.
@@ -2,12 +2,13 @@
2
2
  * Headless themed space-switcher component.
3
3
  *
4
4
  * Renders a trigger button (active-space avatar + name + chevron) that opens a
5
- * dropdown listing all spaces with per-row selection, a "join or create" action,
5
+ * dropdown listing all spaces with per-row selection (+ optional unread badges),
6
+ * a "see all" overflow row, a "join or create" action, a "browse spaces" action,
6
7
  * optional space settings, and an app-provided footer slot (account section, etc.).
7
8
  *
8
9
  * The popup container (Popover on desktop, Sheet on mobile) is fully delegated to
9
10
  * the host via `renderContainer` so this package stays free of modal dependencies.
10
- * Avatars and icons are delegated via render-props for the same reason.
11
+ * Avatars, icons and badges are delegated via render-props for the same reason.
11
12
  *
12
13
  * @example
13
14
  * ```tsx
@@ -27,13 +28,34 @@
27
28
  * )}
28
29
  * renderIcon={(name, size, color) => <Icon name={SWITCHER_ICON[name]} size={size} color={color} />}
29
30
  * renderContainer={({ isOpen, onClose, anchorRef, children }) => (
30
- * <>
31
- * <Popover visible={isOpen} onClose={onClose} anchorRef={anchorRef} placement="bottom-start" width={240}>
32
- * {children}
33
- * </Popover>
34
- * </>
31
+ * <Popover visible={isOpen} onClose={onClose} anchorRef={anchorRef} placement="bottom-start" width={240}>
32
+ * {children}
33
+ * </Popover>
35
34
  * )}
36
- * footerSlot={<AccountSwitcher onRequestClose={...} onViewProfile={...} />}
35
+ * footerSlot={(close) => <AccountSwitcher onRequestClose={close} onViewProfile={...} />}
36
+ * />
37
+ *
38
+ * // OctoChat — appbar variant with bottom Sheet, overflow + badges
39
+ * <SpaceSwitcher
40
+ * spaces={spaces}
41
+ * activeId={activeId}
42
+ * onSelect={(id) => { tapFeedback(); setActiveId(id); }}
43
+ * onAdd={() => router.push('/join')}
44
+ * onBrowse={() => router.push('/spaces/explore')}
45
+ * onSettings={() => router.push(`/space/${activeId}`)}
46
+ * maxVisible={5}
47
+ * onSeeAll={() => router.push('/spaces')}
48
+ * seeAllLabel="See all spaces"
49
+ * variant="appbar"
50
+ * renderTriggerAvatar={(space, size) => <Avatar label={space?.short ?? ''} image={space?.image} size={size} />}
51
+ * renderSpaceAvatar={(space, size) => <Avatar label={space.short ?? ''} image={space.image} size={size} />}
52
+ * renderIcon={(name, size, color) => <Icon name={SWITCHER_ICON[name]} size={size} color={color} />}
53
+ * renderBadge={(count) => <Badge count={count} />}
54
+ * renderTriggerBadge={() => <UnreadDot />}
55
+ * renderContainer={({ isOpen, onClose, children }) => (
56
+ * <BottomSheet visible={isOpen} onClose={onClose}>{children}</BottomSheet>
57
+ * )}
58
+ * footerSlot={(close) => <AccountSwitcher onRequestClose={close} onViewProfile={...} />}
37
59
  * />
38
60
  * ```
39
61
  */
@@ -53,20 +75,35 @@ export interface SwitcherSpace {
53
75
  short?: string;
54
76
  /** Uploaded space image URI; absent → host renders monogram. */
55
77
  image?: string;
78
+ /** Unread message count displayed as a badge on the row. */
79
+ unread?: number;
56
80
  }
57
81
 
58
82
  /** Icon name union for the switcher's built-in glyphs. */
59
- export type SwitcherIconName = 'chevron-down' | 'check' | 'plus' | 'gear';
83
+ export type SwitcherIconName =
84
+ | 'chevron-down'
85
+ | 'chevron-right'
86
+ | 'check'
87
+ | 'plus'
88
+ | 'gear'
89
+ | 'globe';
60
90
 
61
91
  export interface SpaceSwitcherProps {
62
92
  spaces: SwitcherSpace[];
63
93
  activeId?: string | null;
64
94
  /** Called when the user taps a space row. */
65
95
  onSelect: (id: string) => void;
96
+
66
97
  /** "Join or create a space" action. Omit to hide the row. */
67
98
  onAdd?: () => void;
68
99
  /** Override the add-row label. @default "Join or create a space" */
69
100
  addLabel?: string;
101
+
102
+ /** "Browse spaces" action (e.g. a public directory). Omit to hide the row. */
103
+ onBrowse?: () => void;
104
+ /** Override the browse-row label. @default "Browse spaces" */
105
+ browseLabel?: string;
106
+
70
107
  /**
71
108
  * "Space settings" action. Only shown when both `onSettings` and `activeId`
72
109
  * are provided. Omit to hide.
@@ -74,6 +111,19 @@ export interface SpaceSwitcherProps {
74
111
  onSettings?: () => void;
75
112
  /** Override the settings-row label. @default "Space settings" */
76
113
  settingsLabel?: string;
114
+
115
+ /**
116
+ * When set, limits how many space rows are rendered inline.
117
+ * If `spaces.length > maxVisible` AND `onSeeAll` is also set, a "See all"
118
+ * row is appended after the visible rows. Without `onSeeAll`, overflow rows
119
+ * are simply hidden.
120
+ */
121
+ maxVisible?: number;
122
+ /** Called when the user taps the "See all" overflow row. */
123
+ onSeeAll?: () => void;
124
+ /** Override the see-all-row label. @default "See all spaces" */
125
+ seeAllLabel?: string;
126
+
77
127
  /**
78
128
  * Visual variant:
79
129
  * - `'sidebar'` — compact left-aligned trigger for the desktop sidebar header.
@@ -100,6 +150,12 @@ export interface SpaceSwitcherProps {
100
150
  */
101
151
  renderTriggerAvatar?: (space: SwitcherSpace | null, size: number) => React.ReactNode;
102
152
 
153
+ /**
154
+ * Render an overlay node anchored top-right of the trigger avatar — used for
155
+ * an "other spaces have unread" aggregate indicator. Omit to hide the overlay.
156
+ */
157
+ renderTriggerBadge?: () => React.ReactNode;
158
+
103
159
  /**
104
160
  * Render a space row's leading avatar.
105
161
  * Receives the `SwitcherSpace` and a pixel size.
@@ -108,35 +164,23 @@ export interface SpaceSwitcherProps {
108
164
  renderSpaceAvatar?: (space: SwitcherSpace, size: number) => React.ReactNode;
109
165
 
110
166
  /**
111
- * Render an icon glyph. Name is one of `'chevron-down' | 'check' | 'plus' | 'gear'`.
167
+ * Render an icon glyph. Name is one of the `SwitcherIconName` union values.
112
168
  * Omit to hide chevron, check, and action icons (spaces remain selectable).
113
169
  */
114
170
  renderIcon?: (name: SwitcherIconName, size: number, color: string) => React.ReactNode;
115
171
 
116
172
  /**
117
- * Footer rendered below the space list + action rows — use for account-switcher
118
- * sections (with separator if needed). Fully app-owned.
173
+ * Render an unread-count badge on a space row.
174
+ * Receives the count (always > 0 when called). Omit to hide badges.
119
175
  */
120
- footerSlot?: React.ReactNode;
176
+ renderBadge?: (count: number) => React.ReactNode;
121
177
 
122
178
  /**
123
- * When provided, replaces the default "open dropdown" behaviour on trigger press.
124
- * The chevron is also hidden. Use this to navigate directly to a space-details
125
- * page instead of opening a picker — e.g. on desktop where a rail handles
126
- * switching and a dropdown is redundant.
127
- *
128
- * @example
129
- * ```tsx
130
- * // Navigate to space details instead of opening the picker (desktop sidebar)
131
- * <SpaceSwitcher
132
- * onTriggerPress={() => router.push(`/space/${activeId}`)}
133
- * // renderContainer still required but never called when onTriggerPress is set
134
- * renderContainer={() => null}
135
- * ...
136
- * />
137
- * ```
179
+ * Footer rendered below the space list + action rows use for account-switcher
180
+ * sections. Receives `close` so the account section can dismiss the dropdown after
181
+ * an action (e.g. `<AccountSwitcher onRequestClose={close} />`). Fully app-owned.
138
182
  */
139
- onTriggerPress?: () => void;
183
+ footerSlot?: (close: () => void) => React.ReactNode;
140
184
  }
141
185
 
142
186
  // ── Hover-aware Pressable (RN-Web) ────────────────────────────────────────────
@@ -154,15 +198,21 @@ export function SpaceSwitcher({
154
198
  onSelect,
155
199
  onAdd,
156
200
  addLabel = 'Join or create a space',
201
+ onBrowse,
202
+ browseLabel = 'Browse spaces',
157
203
  onSettings,
158
204
  settingsLabel = 'Space settings',
205
+ maxVisible,
206
+ onSeeAll,
207
+ seeAllLabel = 'See all spaces',
159
208
  variant,
160
209
  renderContainer,
161
210
  renderTriggerAvatar,
211
+ renderTriggerBadge,
162
212
  renderSpaceAvatar,
163
213
  renderIcon,
214
+ renderBadge,
164
215
  footerSlot,
165
- onTriggerPress,
166
216
  }: SpaceSwitcherProps) {
167
217
  const theme = useOctoSpacesTheme();
168
218
  const { colors, type: typeScale, fonts, spacing: sp, radii } = theme;
@@ -174,34 +224,32 @@ export function SpaceSwitcher({
174
224
  const active = spaces.find((s) => s.id === activeId) ?? spaces[0] ?? null;
175
225
 
176
226
  const close = () => setOpen(false);
177
- const handleSelect = (id: string) => {
178
- close();
179
- onSelect(id);
180
- };
181
- const handleAdd = () => {
182
- close();
183
- onAdd?.();
184
- };
185
- const handleSettings = () => {
186
- close();
187
- onSettings?.();
188
- };
227
+ const handleSelect = (id: string) => { close(); onSelect(id); };
228
+ const handleAdd = () => { close(); onAdd?.(); };
229
+ const handleBrowse = () => { close(); onBrowse?.(); };
230
+ const handleSettings = () => { close(); onSettings?.(); };
231
+ const handleSeeAll = () => { close(); onSeeAll?.(); };
189
232
 
190
233
  // ── spacing lookups ──────────────────────────────────────────────────────
191
- const sp1 = (sp['1'] as number | undefined) ?? 4;
192
- const sp2 = (sp['2'] as number | undefined) ?? 8;
193
- const sp3 = (sp['3'] as number | undefined) ?? 12;
194
- const sp4 = (sp['4'] as number | undefined) ?? 16;
234
+ const sp1 = (sp['1'] as number | undefined) ?? 4;
235
+ const sp2 = (sp['2'] as number | undefined) ?? 8;
236
+ const sp3 = (sp['3'] as number | undefined) ?? 12;
237
+ const sp4 = (sp['4'] as number | undefined) ?? 16;
195
238
  const radMd = (radii['md'] as number | undefined) ?? 6;
196
239
 
197
- const bodyFont = fonts['body'] ?? undefined;
198
- const bodySize = typeScale['callout']?.size ?? 13;
199
- const bodyLine = typeScale['callout']?.lineHeight ?? 18;
240
+ const bodyFont = fonts['body'] ?? undefined;
241
+ const bodySize = typeScale['callout']?.size ?? 13;
242
+ const bodyLine = typeScale['callout']?.lineHeight ?? 18;
200
243
  const labelSize = typeScale['caption']?.size ?? 11;
201
244
  const labelLine = typeScale['caption']?.lineHeight ?? 16;
202
245
 
246
+ // ── overflow: which rows to show inline ─────────────────────────────────
247
+ const overflow =
248
+ maxVisible != null && onSeeAll != null && spaces.length > maxVisible;
249
+ const visibleSpaces = overflow ? spaces.slice(0, maxVisible) : spaces;
250
+
203
251
  // ── trigger style ────────────────────────────────────────────────────────
204
- const triggerStyle =
252
+ const baseStyle =
205
253
  variant === 'sidebar'
206
254
  ? {
207
255
  flex: 1 as const,
@@ -212,9 +260,6 @@ export function SpaceSwitcher({
212
260
  paddingVertical: sp1 + 2,
213
261
  borderRadius: radMd,
214
262
  minWidth: 0,
215
- backgroundColor: triggerHovered
216
- ? (colors.primarySubtle ?? 'rgba(0,0,0,0.05)')
217
- : 'transparent',
218
263
  }
219
264
  : {
220
265
  flexDirection: 'row' as const,
@@ -224,9 +269,6 @@ export function SpaceSwitcher({
224
269
  paddingHorizontal: sp2,
225
270
  paddingVertical: sp1,
226
271
  borderRadius: radMd,
227
- backgroundColor: triggerHovered
228
- ? (colors.primarySubtle ?? 'rgba(0,0,0,0.05)')
229
- : 'transparent',
230
272
  };
231
273
 
232
274
  // ── dropdown content ─────────────────────────────────────────────────────
@@ -244,7 +286,7 @@ export function SpaceSwitcher({
244
286
  />
245
287
  ) : null}
246
288
 
247
- {spaces.map((s) => (
289
+ {visibleSpaces.map((s) => (
248
290
  <SpaceRow
249
291
  key={s.id}
250
292
  space={s}
@@ -252,6 +294,7 @@ export function SpaceSwitcher({
252
294
  onPress={() => handleSelect(s.id)}
253
295
  renderAvatar={renderSpaceAvatar}
254
296
  renderIcon={renderIcon}
297
+ renderBadge={renderBadge}
255
298
  colors={colors}
256
299
  bodyFont={bodyFont}
257
300
  bodySize={bodySize}
@@ -263,6 +306,23 @@ export function SpaceSwitcher({
263
306
  />
264
307
  ))}
265
308
 
309
+ {overflow ? (
310
+ <ActionRow
311
+ label={seeAllLabel}
312
+ iconName="chevron-right"
313
+ onPress={handleSeeAll}
314
+ renderIcon={renderIcon}
315
+ colors={colors}
316
+ bodyFont={bodyFont}
317
+ bodySize={bodySize}
318
+ bodyLine={bodyLine}
319
+ sp2={sp2}
320
+ sp3={sp3}
321
+ sp4={sp4}
322
+ radMd={radMd}
323
+ />
324
+ ) : null}
325
+
266
326
  {onAdd ? (
267
327
  <ActionRow
268
328
  label={spaces.length > 0 ? addLabel : 'Create your first space'}
@@ -280,6 +340,23 @@ export function SpaceSwitcher({
280
340
  />
281
341
  ) : null}
282
342
 
343
+ {onBrowse ? (
344
+ <ActionRow
345
+ label={browseLabel}
346
+ iconName="globe"
347
+ onPress={handleBrowse}
348
+ renderIcon={renderIcon}
349
+ colors={colors}
350
+ bodyFont={bodyFont}
351
+ bodySize={bodySize}
352
+ bodyLine={bodyLine}
353
+ sp2={sp2}
354
+ sp3={sp3}
355
+ sp4={sp4}
356
+ radMd={radMd}
357
+ />
358
+ ) : null}
359
+
283
360
  {onSettings && active ? (
284
361
  <ActionRow
285
362
  label={settingsLabel}
@@ -307,7 +384,7 @@ export function SpaceSwitcher({
307
384
  marginHorizontal: sp2,
308
385
  }}
309
386
  />
310
- {footerSlot}
387
+ {footerSlot(close)}
311
388
  </>
312
389
  ) : null}
313
390
  </View>
@@ -321,12 +398,28 @@ export function SpaceSwitcher({
321
398
  accessibilityLabel={active ? `${active.name} — switch space` : 'Switch space'}
322
399
  accessibilityState={{ expanded: open }}
323
400
  hitSlop={6}
324
- onPress={onTriggerPress ?? (() => setOpen(true))}
401
+ onPress={() => setOpen(true)}
325
402
  onMouseEnter={() => setTriggerHovered(true)}
326
403
  onMouseLeave={() => setTriggerHovered(false)}
327
- style={triggerStyle}
404
+ style={({ pressed }) => [
405
+ baseStyle,
406
+ {
407
+ backgroundColor: pressed
408
+ ? (colors.primarySubtle ?? 'rgba(0,0,0,0.08)')
409
+ : triggerHovered
410
+ ? (colors.primarySubtle ?? 'rgba(0,0,0,0.05)')
411
+ : 'transparent',
412
+ },
413
+ ]}
328
414
  >
329
- {renderTriggerAvatar ? renderTriggerAvatar(active, 22) : null}
415
+ {renderTriggerAvatar != null || renderTriggerBadge != null ? (
416
+ <View style={styles.avatarWrap}>
417
+ {renderTriggerAvatar ? renderTriggerAvatar(active, 22) : null}
418
+ {renderTriggerBadge ? (
419
+ <View style={styles.triggerBadge}>{renderTriggerBadge()}</View>
420
+ ) : null}
421
+ </View>
422
+ ) : null}
330
423
  <Text
331
424
  numberOfLines={1}
332
425
  style={
@@ -344,16 +437,21 @@ export function SpaceSwitcher({
344
437
  >
345
438
  {active?.name ?? 'Spaces'}
346
439
  </Text>
347
- {!onTriggerPress && renderIcon ? renderIcon('chevron-down', 14, colors.textTertiary) : null}
440
+ {renderIcon ? renderIcon('chevron-down', 14, colors.textTertiary) : null}
348
441
  </Pressable>
349
442
 
350
- {onTriggerPress ? null : renderContainer({ isOpen: open, onClose: close, anchorRef, children: dropdownContent })}
443
+ {renderContainer({ isOpen: open, onClose: close, anchorRef, children: dropdownContent })}
351
444
  </>
352
445
  );
353
446
  }
354
447
 
355
448
  // ── Internal helpers ──────────────────────────────────────────────────────────
356
449
 
450
+ const styles = StyleSheet.create({
451
+ avatarWrap: { position: 'relative' },
452
+ triggerBadge: { position: 'absolute', top: -2, right: -2 },
453
+ });
454
+
357
455
  interface SectionLabelProps {
358
456
  label: string;
359
457
  color: string;
@@ -392,6 +490,7 @@ interface SpaceRowProps {
392
490
  onPress: () => void;
393
491
  renderAvatar?: (space: SwitcherSpace, size: number) => React.ReactNode;
394
492
  renderIcon?: (name: SwitcherIconName, size: number, color: string) => React.ReactNode;
493
+ renderBadge?: (count: number) => React.ReactNode;
395
494
  colors: ReturnType<typeof useOctoSpacesTheme>['colors'];
396
495
  bodyFont: string | undefined;
397
496
  bodySize: number;
@@ -408,6 +507,7 @@ function SpaceRow({
408
507
  onPress,
409
508
  renderAvatar,
410
509
  renderIcon,
510
+ renderBadge,
411
511
  colors,
412
512
  bodyFont,
413
513
  bodySize,
@@ -418,10 +518,7 @@ function SpaceRow({
418
518
  radMd,
419
519
  }: SpaceRowProps) {
420
520
  const [hovered, setHovered] = useState(false);
421
-
422
- const bg = hovered
423
- ? (colors.primarySubtle ?? 'rgba(0,0,0,0.04)')
424
- : 'transparent';
521
+ const unread = space.unread ?? 0;
425
522
 
426
523
  return (
427
524
  <Pressable
@@ -431,15 +528,19 @@ function SpaceRow({
431
528
  onPress={onPress}
432
529
  onMouseEnter={() => setHovered(true)}
433
530
  onMouseLeave={() => setHovered(false)}
434
- style={{
435
- flexDirection: 'row',
436
- alignItems: 'center',
531
+ style={({ pressed }) => ({
532
+ flexDirection: 'row' as const,
533
+ alignItems: 'center' as const,
437
534
  gap: sp3,
438
535
  paddingHorizontal: sp4,
439
536
  paddingVertical: sp2,
440
537
  borderRadius: radMd,
441
- backgroundColor: bg,
442
- }}
538
+ backgroundColor: pressed
539
+ ? (colors.primarySubtle ?? 'rgba(0,0,0,0.08)')
540
+ : hovered
541
+ ? (colors.primarySubtle ?? 'rgba(0,0,0,0.04)')
542
+ : 'transparent',
543
+ })}
443
544
  >
444
545
  {renderAvatar ? renderAvatar(space, 24) : null}
445
546
  <Text
@@ -458,6 +559,7 @@ function SpaceRow({
458
559
  >
459
560
  {space.name}
460
561
  </Text>
562
+ {unread > 0 && renderBadge ? renderBadge(unread) : null}
461
563
  {active && renderIcon ? renderIcon('check', 15, colors.primary) : null}
462
564
  </Pressable>
463
565
  );
@@ -494,10 +596,6 @@ function ActionRow({
494
596
  }: ActionRowProps) {
495
597
  const [hovered, setHovered] = useState(false);
496
598
 
497
- const bg = hovered
498
- ? (colors.primarySubtle ?? 'rgba(0,0,0,0.04)')
499
- : 'transparent';
500
-
501
599
  return (
502
600
  <Pressable
503
601
  accessibilityRole="menuitem"
@@ -505,15 +603,19 @@ function ActionRow({
505
603
  onPress={onPress}
506
604
  onMouseEnter={() => setHovered(true)}
507
605
  onMouseLeave={() => setHovered(false)}
508
- style={{
509
- flexDirection: 'row',
510
- alignItems: 'center',
606
+ style={({ pressed }) => ({
607
+ flexDirection: 'row' as const,
608
+ alignItems: 'center' as const,
511
609
  gap: sp3,
512
610
  paddingHorizontal: sp4,
513
611
  paddingVertical: sp2,
514
612
  borderRadius: radMd,
515
- backgroundColor: bg,
516
- }}
613
+ backgroundColor: pressed
614
+ ? (colors.primarySubtle ?? 'rgba(0,0,0,0.08)')
615
+ : hovered
616
+ ? (colors.primarySubtle ?? 'rgba(0,0,0,0.04)')
617
+ : 'transparent',
618
+ })}
517
619
  >
518
620
  {renderIcon ? (
519
621
  <View style={{ width: 24, alignItems: 'center', justifyContent: 'center' }}>