@drakkar.software/octospaces-ui 0.4.0 → 0.4.2

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,541 @@
1
+ /**
2
+ * Headless themed space-switcher component.
3
+ *
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,
6
+ * optional space settings, and an app-provided footer slot (account section, etc.).
7
+ *
8
+ * The popup container (Popover on desktop, Sheet on mobile) is fully delegated to
9
+ * 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
+ *
12
+ * @example
13
+ * ```tsx
14
+ * // OctoVault — sidebar variant with Popover container
15
+ * <SpaceSwitcher
16
+ * spaces={spaces}
17
+ * activeId={activeId}
18
+ * onSelect={switchSpace}
19
+ * onAdd={() => router.push('/join')}
20
+ * onSettings={() => router.push(`/space/${activeId}`)}
21
+ * variant="sidebar"
22
+ * renderTriggerAvatar={(space, size) => (
23
+ * <Avatar label={space?.short ?? ''} image={space?.image} size={size} />
24
+ * )}
25
+ * renderSpaceAvatar={(space, size) => (
26
+ * <Avatar label={space.short ?? ''} image={space.image} size={size} />
27
+ * )}
28
+ * renderIcon={(name, size, color) => <Icon name={SWITCHER_ICON[name]} size={size} color={color} />}
29
+ * renderContainer={({ isOpen, onClose, anchorRef, children }) => (
30
+ * <>
31
+ * <Popover visible={isOpen} onClose={onClose} anchorRef={anchorRef} placement="bottom-start" width={240}>
32
+ * {children}
33
+ * </Popover>
34
+ * </>
35
+ * )}
36
+ * footerSlot={<AccountSwitcher onRequestClose={...} onViewProfile={...} />}
37
+ * />
38
+ * ```
39
+ */
40
+ import React, { useRef, useState } from 'react';
41
+ import { Pressable as RNPressable, StyleSheet, Text, View } from 'react-native';
42
+ import type { PressableProps, TextStyle, View as RNView } from 'react-native';
43
+
44
+ import { useOctoSpacesTheme } from '../theme/provider.js';
45
+
46
+ // ── Types ─────────────────────────────────────────────────────────────────────
47
+
48
+ /** Structural space item — no SDK dependency. */
49
+ export interface SwitcherSpace {
50
+ id: string;
51
+ name: string;
52
+ /** 2-letter monogram used as avatar fallback. */
53
+ short?: string;
54
+ /** Uploaded space image URI; absent → host renders monogram. */
55
+ image?: string;
56
+ }
57
+
58
+ /** Icon name union for the switcher's built-in glyphs. */
59
+ export type SwitcherIconName = 'chevron-down' | 'check' | 'plus' | 'gear';
60
+
61
+ export interface SpaceSwitcherProps {
62
+ spaces: SwitcherSpace[];
63
+ activeId?: string | null;
64
+ /** Called when the user taps a space row. */
65
+ onSelect: (id: string) => void;
66
+ /** "Join or create a space" action. Omit to hide the row. */
67
+ onAdd?: () => void;
68
+ /** Override the add-row label. @default "Join or create a space" */
69
+ addLabel?: string;
70
+ /**
71
+ * "Space settings" action. Only shown when both `onSettings` and `activeId`
72
+ * are provided. Omit to hide.
73
+ */
74
+ onSettings?: () => void;
75
+ /** Override the settings-row label. @default "Space settings" */
76
+ settingsLabel?: string;
77
+ /**
78
+ * Visual variant:
79
+ * - `'sidebar'` — compact left-aligned trigger for the desktop sidebar header.
80
+ * - `'appbar'` — centered trigger for a phone app-bar title area.
81
+ */
82
+ variant: 'sidebar' | 'appbar';
83
+
84
+ /**
85
+ * Wraps the dropdown content in the host app's container (Popover / Sheet).
86
+ * Called with `{ isOpen, onClose, anchorRef, children }` — must render
87
+ * children inside an appropriate modal surface.
88
+ */
89
+ renderContainer: (props: {
90
+ isOpen: boolean;
91
+ onClose: () => void;
92
+ anchorRef: React.RefObject<RNView>;
93
+ children: React.ReactNode;
94
+ }) => React.ReactNode;
95
+
96
+ /**
97
+ * Render the active-space avatar inside the trigger button.
98
+ * Receives the active `SwitcherSpace` (or `null` when none) and a pixel size.
99
+ * Omit to render nothing in the avatar slot.
100
+ */
101
+ renderTriggerAvatar?: (space: SwitcherSpace | null, size: number) => React.ReactNode;
102
+
103
+ /**
104
+ * Render a space row's leading avatar.
105
+ * Receives the `SwitcherSpace` and a pixel size.
106
+ * Omit to render nothing in the leading slot.
107
+ */
108
+ renderSpaceAvatar?: (space: SwitcherSpace, size: number) => React.ReactNode;
109
+
110
+ /**
111
+ * Render an icon glyph. Name is one of `'chevron-down' | 'check' | 'plus' | 'gear'`.
112
+ * Omit to hide chevron, check, and action icons (spaces remain selectable).
113
+ */
114
+ renderIcon?: (name: SwitcherIconName, size: number, color: string) => React.ReactNode;
115
+
116
+ /**
117
+ * Footer rendered below the space list + action rows — use for account-switcher
118
+ * sections (with separator if needed). Fully app-owned.
119
+ */
120
+ footerSlot?: React.ReactNode;
121
+
122
+ /**
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
+ * ```
138
+ */
139
+ onTriggerPress?: () => void;
140
+ }
141
+
142
+ // ── Hover-aware Pressable (RN-Web) ────────────────────────────────────────────
143
+
144
+ type HoverProps = { onMouseEnter?: () => void; onMouseLeave?: () => void };
145
+ const Pressable = RNPressable as React.ForwardRefExoticComponent<
146
+ PressableProps & HoverProps & React.RefAttributes<RNView>
147
+ >;
148
+
149
+ // ── Component ─────────────────────────────────────────────────────────────────
150
+
151
+ export function SpaceSwitcher({
152
+ spaces,
153
+ activeId,
154
+ onSelect,
155
+ onAdd,
156
+ addLabel = 'Join or create a space',
157
+ onSettings,
158
+ settingsLabel = 'Space settings',
159
+ variant,
160
+ renderContainer,
161
+ renderTriggerAvatar,
162
+ renderSpaceAvatar,
163
+ renderIcon,
164
+ footerSlot,
165
+ onTriggerPress,
166
+ }: SpaceSwitcherProps) {
167
+ const theme = useOctoSpacesTheme();
168
+ const { colors, type: typeScale, fonts, spacing: sp, radii } = theme;
169
+
170
+ const [open, setOpen] = useState(false);
171
+ const [triggerHovered, setTriggerHovered] = useState(false);
172
+ const anchorRef = useRef<RNView>(null);
173
+
174
+ const active = spaces.find((s) => s.id === activeId) ?? spaces[0] ?? null;
175
+
176
+ 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
+ };
189
+
190
+ // ── 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;
195
+ const radMd = (radii['md'] as number | undefined) ?? 6;
196
+
197
+ const bodyFont = fonts['body'] ?? undefined;
198
+ const bodySize = typeScale['callout']?.size ?? 13;
199
+ const bodyLine = typeScale['callout']?.lineHeight ?? 18;
200
+ const labelSize = typeScale['caption']?.size ?? 11;
201
+ const labelLine = typeScale['caption']?.lineHeight ?? 16;
202
+
203
+ // ── trigger style ────────────────────────────────────────────────────────
204
+ const triggerStyle =
205
+ variant === 'sidebar'
206
+ ? {
207
+ flex: 1 as const,
208
+ flexDirection: 'row' as const,
209
+ alignItems: 'center' as const,
210
+ gap: sp2,
211
+ paddingHorizontal: sp2,
212
+ paddingVertical: sp1 + 2,
213
+ borderRadius: radMd,
214
+ minWidth: 0,
215
+ backgroundColor: triggerHovered
216
+ ? (colors.primarySubtle ?? 'rgba(0,0,0,0.05)')
217
+ : 'transparent',
218
+ }
219
+ : {
220
+ flexDirection: 'row' as const,
221
+ alignItems: 'center' as const,
222
+ justifyContent: 'center' as const,
223
+ gap: sp2,
224
+ paddingHorizontal: sp2,
225
+ paddingVertical: sp1,
226
+ borderRadius: radMd,
227
+ backgroundColor: triggerHovered
228
+ ? (colors.primarySubtle ?? 'rgba(0,0,0,0.05)')
229
+ : 'transparent',
230
+ };
231
+
232
+ // ── dropdown content ─────────────────────────────────────────────────────
233
+ const dropdownContent = (
234
+ <View style={{ paddingVertical: sp1 }}>
235
+ {spaces.length > 0 ? (
236
+ <SectionLabel
237
+ label="Spaces"
238
+ color={colors.textTertiary}
239
+ size={labelSize}
240
+ lineHeight={labelLine}
241
+ font={bodyFont}
242
+ paddingH={sp4}
243
+ paddingV={sp1}
244
+ />
245
+ ) : null}
246
+
247
+ {spaces.map((s) => (
248
+ <SpaceRow
249
+ key={s.id}
250
+ space={s}
251
+ active={s.id === (active?.id ?? null)}
252
+ onPress={() => handleSelect(s.id)}
253
+ renderAvatar={renderSpaceAvatar}
254
+ renderIcon={renderIcon}
255
+ colors={colors}
256
+ bodyFont={bodyFont}
257
+ bodySize={bodySize}
258
+ bodyLine={bodyLine}
259
+ sp2={sp2}
260
+ sp3={sp3}
261
+ sp4={sp4}
262
+ radMd={radMd}
263
+ />
264
+ ))}
265
+
266
+ {onAdd ? (
267
+ <ActionRow
268
+ label={spaces.length > 0 ? addLabel : 'Create your first space'}
269
+ iconName="plus"
270
+ onPress={handleAdd}
271
+ renderIcon={renderIcon}
272
+ colors={colors}
273
+ bodyFont={bodyFont}
274
+ bodySize={bodySize}
275
+ bodyLine={bodyLine}
276
+ sp2={sp2}
277
+ sp3={sp3}
278
+ sp4={sp4}
279
+ radMd={radMd}
280
+ />
281
+ ) : null}
282
+
283
+ {onSettings && active ? (
284
+ <ActionRow
285
+ label={settingsLabel}
286
+ iconName="gear"
287
+ onPress={handleSettings}
288
+ renderIcon={renderIcon}
289
+ colors={colors}
290
+ bodyFont={bodyFont}
291
+ bodySize={bodySize}
292
+ bodyLine={bodyLine}
293
+ sp2={sp2}
294
+ sp3={sp3}
295
+ sp4={sp4}
296
+ radMd={radMd}
297
+ />
298
+ ) : null}
299
+
300
+ {footerSlot != null ? (
301
+ <>
302
+ <View
303
+ style={{
304
+ height: StyleSheet.hairlineWidth,
305
+ backgroundColor: colors.borderSubtle,
306
+ marginVertical: sp1,
307
+ marginHorizontal: sp2,
308
+ }}
309
+ />
310
+ {footerSlot}
311
+ </>
312
+ ) : null}
313
+ </View>
314
+ );
315
+
316
+ return (
317
+ <>
318
+ <Pressable
319
+ ref={anchorRef}
320
+ accessibilityRole="button"
321
+ accessibilityLabel={active ? `${active.name} — switch space` : 'Switch space'}
322
+ accessibilityState={{ expanded: open }}
323
+ hitSlop={6}
324
+ onPress={onTriggerPress ?? (() => setOpen(true))}
325
+ onMouseEnter={() => setTriggerHovered(true)}
326
+ onMouseLeave={() => setTriggerHovered(false)}
327
+ style={triggerStyle}
328
+ >
329
+ {renderTriggerAvatar ? renderTriggerAvatar(active, 22) : null}
330
+ <Text
331
+ numberOfLines={1}
332
+ style={
333
+ {
334
+ flex: variant === 'sidebar' ? 1 : undefined,
335
+ minWidth: 0,
336
+ flexShrink: 1,
337
+ fontSize: typeScale['heading']?.size ?? 15,
338
+ lineHeight: typeScale['heading']?.lineHeight ?? 20,
339
+ fontWeight: '600',
340
+ color: colors.text,
341
+ fontFamily: bodyFont,
342
+ } as TextStyle
343
+ }
344
+ >
345
+ {active?.name ?? 'Spaces'}
346
+ </Text>
347
+ {!onTriggerPress && renderIcon ? renderIcon('chevron-down', 14, colors.textTertiary) : null}
348
+ </Pressable>
349
+
350
+ {onTriggerPress ? null : renderContainer({ isOpen: open, onClose: close, anchorRef, children: dropdownContent })}
351
+ </>
352
+ );
353
+ }
354
+
355
+ // ── Internal helpers ──────────────────────────────────────────────────────────
356
+
357
+ interface SectionLabelProps {
358
+ label: string;
359
+ color: string;
360
+ size: number;
361
+ lineHeight: number;
362
+ font: string | undefined;
363
+ paddingH: number;
364
+ paddingV: number;
365
+ }
366
+
367
+ function SectionLabel({ label, color, size, lineHeight, font, paddingH, paddingV }: SectionLabelProps) {
368
+ return (
369
+ <Text
370
+ style={
371
+ {
372
+ fontSize: size,
373
+ lineHeight,
374
+ fontWeight: '600',
375
+ color,
376
+ fontFamily: font,
377
+ textTransform: 'uppercase',
378
+ letterSpacing: 0.5,
379
+ paddingHorizontal: paddingH,
380
+ paddingVertical: paddingV,
381
+ } as TextStyle
382
+ }
383
+ >
384
+ {label}
385
+ </Text>
386
+ );
387
+ }
388
+
389
+ interface SpaceRowProps {
390
+ space: SwitcherSpace;
391
+ active: boolean;
392
+ onPress: () => void;
393
+ renderAvatar?: (space: SwitcherSpace, size: number) => React.ReactNode;
394
+ renderIcon?: (name: SwitcherIconName, size: number, color: string) => React.ReactNode;
395
+ colors: ReturnType<typeof useOctoSpacesTheme>['colors'];
396
+ bodyFont: string | undefined;
397
+ bodySize: number;
398
+ bodyLine: number;
399
+ sp2: number;
400
+ sp3: number;
401
+ sp4: number;
402
+ radMd: number;
403
+ }
404
+
405
+ function SpaceRow({
406
+ space,
407
+ active,
408
+ onPress,
409
+ renderAvatar,
410
+ renderIcon,
411
+ colors,
412
+ bodyFont,
413
+ bodySize,
414
+ bodyLine,
415
+ sp2,
416
+ sp3,
417
+ sp4,
418
+ radMd,
419
+ }: SpaceRowProps) {
420
+ const [hovered, setHovered] = useState(false);
421
+
422
+ const bg = hovered
423
+ ? (colors.primarySubtle ?? 'rgba(0,0,0,0.04)')
424
+ : 'transparent';
425
+
426
+ return (
427
+ <Pressable
428
+ accessibilityRole="menuitem"
429
+ accessibilityLabel={active ? `${space.name} (current)` : `Switch to ${space.name}`}
430
+ accessibilityState={{ selected: active }}
431
+ onPress={onPress}
432
+ onMouseEnter={() => setHovered(true)}
433
+ onMouseLeave={() => setHovered(false)}
434
+ style={{
435
+ flexDirection: 'row',
436
+ alignItems: 'center',
437
+ gap: sp3,
438
+ paddingHorizontal: sp4,
439
+ paddingVertical: sp2,
440
+ borderRadius: radMd,
441
+ backgroundColor: bg,
442
+ }}
443
+ >
444
+ {renderAvatar ? renderAvatar(space, 24) : null}
445
+ <Text
446
+ numberOfLines={1}
447
+ style={
448
+ {
449
+ flex: 1,
450
+ minWidth: 0,
451
+ fontSize: bodySize,
452
+ lineHeight: bodyLine,
453
+ fontWeight: active ? '600' : '400',
454
+ color: active ? colors.primary : colors.text,
455
+ fontFamily: bodyFont,
456
+ } as TextStyle
457
+ }
458
+ >
459
+ {space.name}
460
+ </Text>
461
+ {active && renderIcon ? renderIcon('check', 15, colors.primary) : null}
462
+ </Pressable>
463
+ );
464
+ }
465
+
466
+ interface ActionRowProps {
467
+ label: string;
468
+ iconName: SwitcherIconName;
469
+ onPress: () => void;
470
+ renderIcon?: (name: SwitcherIconName, size: number, color: string) => React.ReactNode;
471
+ colors: ReturnType<typeof useOctoSpacesTheme>['colors'];
472
+ bodyFont: string | undefined;
473
+ bodySize: number;
474
+ bodyLine: number;
475
+ sp2: number;
476
+ sp3: number;
477
+ sp4: number;
478
+ radMd: number;
479
+ }
480
+
481
+ function ActionRow({
482
+ label,
483
+ iconName,
484
+ onPress,
485
+ renderIcon,
486
+ colors,
487
+ bodyFont,
488
+ bodySize,
489
+ bodyLine,
490
+ sp2,
491
+ sp3,
492
+ sp4,
493
+ radMd,
494
+ }: ActionRowProps) {
495
+ const [hovered, setHovered] = useState(false);
496
+
497
+ const bg = hovered
498
+ ? (colors.primarySubtle ?? 'rgba(0,0,0,0.04)')
499
+ : 'transparent';
500
+
501
+ return (
502
+ <Pressable
503
+ accessibilityRole="menuitem"
504
+ accessibilityLabel={label}
505
+ onPress={onPress}
506
+ onMouseEnter={() => setHovered(true)}
507
+ onMouseLeave={() => setHovered(false)}
508
+ style={{
509
+ flexDirection: 'row',
510
+ alignItems: 'center',
511
+ gap: sp3,
512
+ paddingHorizontal: sp4,
513
+ paddingVertical: sp2,
514
+ borderRadius: radMd,
515
+ backgroundColor: bg,
516
+ }}
517
+ >
518
+ {renderIcon ? (
519
+ <View style={{ width: 24, alignItems: 'center', justifyContent: 'center' }}>
520
+ {renderIcon(iconName, 15, colors.textSecondary)}
521
+ </View>
522
+ ) : null}
523
+ <Text
524
+ numberOfLines={1}
525
+ style={
526
+ {
527
+ flex: 1,
528
+ minWidth: 0,
529
+ fontSize: bodySize,
530
+ lineHeight: bodyLine,
531
+ fontWeight: '400',
532
+ color: colors.text,
533
+ fontFamily: bodyFont,
534
+ } as TextStyle
535
+ }
536
+ >
537
+ {label}
538
+ </Text>
539
+ </Pressable>
540
+ );
541
+ }
@@ -9,3 +9,5 @@ export type { SidebarActionButtonProps } from './SidebarActionButton.js';
9
9
  export { SidebarActionButton } from './SidebarActionButton.js';
10
10
  export type { SidebarItemProps } from './SidebarItem.js';
11
11
  export { SidebarItem } from './SidebarItem.js';
12
+ export type { SwitcherSpace, SwitcherIconName, SpaceSwitcherProps } from './SpaceSwitcher.js';
13
+ export { SpaceSwitcher } from './SpaceSwitcher.js';