@drakkar.software/octospaces-ui 0.4.1 → 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,16 +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
 
172
+ /**
173
+ * Render an unread-count badge on a space row.
174
+ * Receives the count (always > 0 when called). Omit to hide badges.
175
+ */
176
+ renderBadge?: (count: number) => React.ReactNode;
177
+
116
178
  /**
117
179
  * Footer rendered below the space list + action rows — use for account-switcher
118
- * sections (with separator if needed). Fully app-owned.
180
+ * sections. Receives `close` so the account section can dismiss the dropdown after
181
+ * an action (e.g. `<AccountSwitcher onRequestClose={close} />`). Fully app-owned.
119
182
  */
120
- footerSlot?: React.ReactNode;
183
+ footerSlot?: (close: () => void) => React.ReactNode;
121
184
  }
122
185
 
123
186
  // ── Hover-aware Pressable (RN-Web) ────────────────────────────────────────────
@@ -135,13 +198,20 @@ export function SpaceSwitcher({
135
198
  onSelect,
136
199
  onAdd,
137
200
  addLabel = 'Join or create a space',
201
+ onBrowse,
202
+ browseLabel = 'Browse spaces',
138
203
  onSettings,
139
204
  settingsLabel = 'Space settings',
205
+ maxVisible,
206
+ onSeeAll,
207
+ seeAllLabel = 'See all spaces',
140
208
  variant,
141
209
  renderContainer,
142
210
  renderTriggerAvatar,
211
+ renderTriggerBadge,
143
212
  renderSpaceAvatar,
144
213
  renderIcon,
214
+ renderBadge,
145
215
  footerSlot,
146
216
  }: SpaceSwitcherProps) {
147
217
  const theme = useOctoSpacesTheme();
@@ -154,34 +224,32 @@ export function SpaceSwitcher({
154
224
  const active = spaces.find((s) => s.id === activeId) ?? spaces[0] ?? null;
155
225
 
156
226
  const close = () => setOpen(false);
157
- const handleSelect = (id: string) => {
158
- close();
159
- onSelect(id);
160
- };
161
- const handleAdd = () => {
162
- close();
163
- onAdd?.();
164
- };
165
- const handleSettings = () => {
166
- close();
167
- onSettings?.();
168
- };
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?.(); };
169
232
 
170
233
  // ── spacing lookups ──────────────────────────────────────────────────────
171
- const sp1 = (sp['1'] as number | undefined) ?? 4;
172
- const sp2 = (sp['2'] as number | undefined) ?? 8;
173
- const sp3 = (sp['3'] as number | undefined) ?? 12;
174
- 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;
175
238
  const radMd = (radii['md'] as number | undefined) ?? 6;
176
239
 
177
- const bodyFont = fonts['body'] ?? undefined;
178
- const bodySize = typeScale['callout']?.size ?? 13;
179
- 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;
180
243
  const labelSize = typeScale['caption']?.size ?? 11;
181
244
  const labelLine = typeScale['caption']?.lineHeight ?? 16;
182
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
+
183
251
  // ── trigger style ────────────────────────────────────────────────────────
184
- const triggerStyle =
252
+ const baseStyle =
185
253
  variant === 'sidebar'
186
254
  ? {
187
255
  flex: 1 as const,
@@ -192,9 +260,6 @@ export function SpaceSwitcher({
192
260
  paddingVertical: sp1 + 2,
193
261
  borderRadius: radMd,
194
262
  minWidth: 0,
195
- backgroundColor: triggerHovered
196
- ? (colors.primarySubtle ?? 'rgba(0,0,0,0.05)')
197
- : 'transparent',
198
263
  }
199
264
  : {
200
265
  flexDirection: 'row' as const,
@@ -204,9 +269,6 @@ export function SpaceSwitcher({
204
269
  paddingHorizontal: sp2,
205
270
  paddingVertical: sp1,
206
271
  borderRadius: radMd,
207
- backgroundColor: triggerHovered
208
- ? (colors.primarySubtle ?? 'rgba(0,0,0,0.05)')
209
- : 'transparent',
210
272
  };
211
273
 
212
274
  // ── dropdown content ─────────────────────────────────────────────────────
@@ -224,7 +286,7 @@ export function SpaceSwitcher({
224
286
  />
225
287
  ) : null}
226
288
 
227
- {spaces.map((s) => (
289
+ {visibleSpaces.map((s) => (
228
290
  <SpaceRow
229
291
  key={s.id}
230
292
  space={s}
@@ -232,6 +294,7 @@ export function SpaceSwitcher({
232
294
  onPress={() => handleSelect(s.id)}
233
295
  renderAvatar={renderSpaceAvatar}
234
296
  renderIcon={renderIcon}
297
+ renderBadge={renderBadge}
235
298
  colors={colors}
236
299
  bodyFont={bodyFont}
237
300
  bodySize={bodySize}
@@ -243,6 +306,23 @@ export function SpaceSwitcher({
243
306
  />
244
307
  ))}
245
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
+
246
326
  {onAdd ? (
247
327
  <ActionRow
248
328
  label={spaces.length > 0 ? addLabel : 'Create your first space'}
@@ -260,6 +340,23 @@ export function SpaceSwitcher({
260
340
  />
261
341
  ) : null}
262
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
+
263
360
  {onSettings && active ? (
264
361
  <ActionRow
265
362
  label={settingsLabel}
@@ -287,7 +384,7 @@ export function SpaceSwitcher({
287
384
  marginHorizontal: sp2,
288
385
  }}
289
386
  />
290
- {footerSlot}
387
+ {footerSlot(close)}
291
388
  </>
292
389
  ) : null}
293
390
  </View>
@@ -304,9 +401,25 @@ export function SpaceSwitcher({
304
401
  onPress={() => setOpen(true)}
305
402
  onMouseEnter={() => setTriggerHovered(true)}
306
403
  onMouseLeave={() => setTriggerHovered(false)}
307
- 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
+ ]}
308
414
  >
309
- {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}
310
423
  <Text
311
424
  numberOfLines={1}
312
425
  style={
@@ -334,6 +447,11 @@ export function SpaceSwitcher({
334
447
 
335
448
  // ── Internal helpers ──────────────────────────────────────────────────────────
336
449
 
450
+ const styles = StyleSheet.create({
451
+ avatarWrap: { position: 'relative' },
452
+ triggerBadge: { position: 'absolute', top: -2, right: -2 },
453
+ });
454
+
337
455
  interface SectionLabelProps {
338
456
  label: string;
339
457
  color: string;
@@ -372,6 +490,7 @@ interface SpaceRowProps {
372
490
  onPress: () => void;
373
491
  renderAvatar?: (space: SwitcherSpace, size: number) => React.ReactNode;
374
492
  renderIcon?: (name: SwitcherIconName, size: number, color: string) => React.ReactNode;
493
+ renderBadge?: (count: number) => React.ReactNode;
375
494
  colors: ReturnType<typeof useOctoSpacesTheme>['colors'];
376
495
  bodyFont: string | undefined;
377
496
  bodySize: number;
@@ -388,6 +507,7 @@ function SpaceRow({
388
507
  onPress,
389
508
  renderAvatar,
390
509
  renderIcon,
510
+ renderBadge,
391
511
  colors,
392
512
  bodyFont,
393
513
  bodySize,
@@ -398,10 +518,7 @@ function SpaceRow({
398
518
  radMd,
399
519
  }: SpaceRowProps) {
400
520
  const [hovered, setHovered] = useState(false);
401
-
402
- const bg = hovered
403
- ? (colors.primarySubtle ?? 'rgba(0,0,0,0.04)')
404
- : 'transparent';
521
+ const unread = space.unread ?? 0;
405
522
 
406
523
  return (
407
524
  <Pressable
@@ -411,15 +528,19 @@ function SpaceRow({
411
528
  onPress={onPress}
412
529
  onMouseEnter={() => setHovered(true)}
413
530
  onMouseLeave={() => setHovered(false)}
414
- style={{
415
- flexDirection: 'row',
416
- alignItems: 'center',
531
+ style={({ pressed }) => ({
532
+ flexDirection: 'row' as const,
533
+ alignItems: 'center' as const,
417
534
  gap: sp3,
418
535
  paddingHorizontal: sp4,
419
536
  paddingVertical: sp2,
420
537
  borderRadius: radMd,
421
- backgroundColor: bg,
422
- }}
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
+ })}
423
544
  >
424
545
  {renderAvatar ? renderAvatar(space, 24) : null}
425
546
  <Text
@@ -438,6 +559,7 @@ function SpaceRow({
438
559
  >
439
560
  {space.name}
440
561
  </Text>
562
+ {unread > 0 && renderBadge ? renderBadge(unread) : null}
441
563
  {active && renderIcon ? renderIcon('check', 15, colors.primary) : null}
442
564
  </Pressable>
443
565
  );
@@ -474,10 +596,6 @@ function ActionRow({
474
596
  }: ActionRowProps) {
475
597
  const [hovered, setHovered] = useState(false);
476
598
 
477
- const bg = hovered
478
- ? (colors.primarySubtle ?? 'rgba(0,0,0,0.04)')
479
- : 'transparent';
480
-
481
599
  return (
482
600
  <Pressable
483
601
  accessibilityRole="menuitem"
@@ -485,15 +603,19 @@ function ActionRow({
485
603
  onPress={onPress}
486
604
  onMouseEnter={() => setHovered(true)}
487
605
  onMouseLeave={() => setHovered(false)}
488
- style={{
489
- flexDirection: 'row',
490
- alignItems: 'center',
606
+ style={({ pressed }) => ({
607
+ flexDirection: 'row' as const,
608
+ alignItems: 'center' as const,
491
609
  gap: sp3,
492
610
  paddingHorizontal: sp4,
493
611
  paddingVertical: sp2,
494
612
  borderRadius: radMd,
495
- backgroundColor: bg,
496
- }}
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
+ })}
497
619
  >
498
620
  {renderIcon ? (
499
621
  <View style={{ width: 24, alignItems: 'center', justifyContent: 'center' }}>