@drakkar.software/octospaces-ui 0.4.0 → 0.4.1

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,521 @@
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
+ // ── Hover-aware Pressable (RN-Web) ────────────────────────────────────────────
124
+
125
+ type HoverProps = { onMouseEnter?: () => void; onMouseLeave?: () => void };
126
+ const Pressable = RNPressable as React.ForwardRefExoticComponent<
127
+ PressableProps & HoverProps & React.RefAttributes<RNView>
128
+ >;
129
+
130
+ // ── Component ─────────────────────────────────────────────────────────────────
131
+
132
+ export function SpaceSwitcher({
133
+ spaces,
134
+ activeId,
135
+ onSelect,
136
+ onAdd,
137
+ addLabel = 'Join or create a space',
138
+ onSettings,
139
+ settingsLabel = 'Space settings',
140
+ variant,
141
+ renderContainer,
142
+ renderTriggerAvatar,
143
+ renderSpaceAvatar,
144
+ renderIcon,
145
+ footerSlot,
146
+ }: SpaceSwitcherProps) {
147
+ const theme = useOctoSpacesTheme();
148
+ const { colors, type: typeScale, fonts, spacing: sp, radii } = theme;
149
+
150
+ const [open, setOpen] = useState(false);
151
+ const [triggerHovered, setTriggerHovered] = useState(false);
152
+ const anchorRef = useRef<RNView>(null);
153
+
154
+ const active = spaces.find((s) => s.id === activeId) ?? spaces[0] ?? null;
155
+
156
+ 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
+ };
169
+
170
+ // ── 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;
175
+ const radMd = (radii['md'] as number | undefined) ?? 6;
176
+
177
+ const bodyFont = fonts['body'] ?? undefined;
178
+ const bodySize = typeScale['callout']?.size ?? 13;
179
+ const bodyLine = typeScale['callout']?.lineHeight ?? 18;
180
+ const labelSize = typeScale['caption']?.size ?? 11;
181
+ const labelLine = typeScale['caption']?.lineHeight ?? 16;
182
+
183
+ // ── trigger style ────────────────────────────────────────────────────────
184
+ const triggerStyle =
185
+ variant === 'sidebar'
186
+ ? {
187
+ flex: 1 as const,
188
+ flexDirection: 'row' as const,
189
+ alignItems: 'center' as const,
190
+ gap: sp2,
191
+ paddingHorizontal: sp2,
192
+ paddingVertical: sp1 + 2,
193
+ borderRadius: radMd,
194
+ minWidth: 0,
195
+ backgroundColor: triggerHovered
196
+ ? (colors.primarySubtle ?? 'rgba(0,0,0,0.05)')
197
+ : 'transparent',
198
+ }
199
+ : {
200
+ flexDirection: 'row' as const,
201
+ alignItems: 'center' as const,
202
+ justifyContent: 'center' as const,
203
+ gap: sp2,
204
+ paddingHorizontal: sp2,
205
+ paddingVertical: sp1,
206
+ borderRadius: radMd,
207
+ backgroundColor: triggerHovered
208
+ ? (colors.primarySubtle ?? 'rgba(0,0,0,0.05)')
209
+ : 'transparent',
210
+ };
211
+
212
+ // ── dropdown content ─────────────────────────────────────────────────────
213
+ const dropdownContent = (
214
+ <View style={{ paddingVertical: sp1 }}>
215
+ {spaces.length > 0 ? (
216
+ <SectionLabel
217
+ label="Spaces"
218
+ color={colors.textTertiary}
219
+ size={labelSize}
220
+ lineHeight={labelLine}
221
+ font={bodyFont}
222
+ paddingH={sp4}
223
+ paddingV={sp1}
224
+ />
225
+ ) : null}
226
+
227
+ {spaces.map((s) => (
228
+ <SpaceRow
229
+ key={s.id}
230
+ space={s}
231
+ active={s.id === (active?.id ?? null)}
232
+ onPress={() => handleSelect(s.id)}
233
+ renderAvatar={renderSpaceAvatar}
234
+ renderIcon={renderIcon}
235
+ colors={colors}
236
+ bodyFont={bodyFont}
237
+ bodySize={bodySize}
238
+ bodyLine={bodyLine}
239
+ sp2={sp2}
240
+ sp3={sp3}
241
+ sp4={sp4}
242
+ radMd={radMd}
243
+ />
244
+ ))}
245
+
246
+ {onAdd ? (
247
+ <ActionRow
248
+ label={spaces.length > 0 ? addLabel : 'Create your first space'}
249
+ iconName="plus"
250
+ onPress={handleAdd}
251
+ renderIcon={renderIcon}
252
+ colors={colors}
253
+ bodyFont={bodyFont}
254
+ bodySize={bodySize}
255
+ bodyLine={bodyLine}
256
+ sp2={sp2}
257
+ sp3={sp3}
258
+ sp4={sp4}
259
+ radMd={radMd}
260
+ />
261
+ ) : null}
262
+
263
+ {onSettings && active ? (
264
+ <ActionRow
265
+ label={settingsLabel}
266
+ iconName="gear"
267
+ onPress={handleSettings}
268
+ renderIcon={renderIcon}
269
+ colors={colors}
270
+ bodyFont={bodyFont}
271
+ bodySize={bodySize}
272
+ bodyLine={bodyLine}
273
+ sp2={sp2}
274
+ sp3={sp3}
275
+ sp4={sp4}
276
+ radMd={radMd}
277
+ />
278
+ ) : null}
279
+
280
+ {footerSlot != null ? (
281
+ <>
282
+ <View
283
+ style={{
284
+ height: StyleSheet.hairlineWidth,
285
+ backgroundColor: colors.borderSubtle,
286
+ marginVertical: sp1,
287
+ marginHorizontal: sp2,
288
+ }}
289
+ />
290
+ {footerSlot}
291
+ </>
292
+ ) : null}
293
+ </View>
294
+ );
295
+
296
+ return (
297
+ <>
298
+ <Pressable
299
+ ref={anchorRef}
300
+ accessibilityRole="button"
301
+ accessibilityLabel={active ? `${active.name} — switch space` : 'Switch space'}
302
+ accessibilityState={{ expanded: open }}
303
+ hitSlop={6}
304
+ onPress={() => setOpen(true)}
305
+ onMouseEnter={() => setTriggerHovered(true)}
306
+ onMouseLeave={() => setTriggerHovered(false)}
307
+ style={triggerStyle}
308
+ >
309
+ {renderTriggerAvatar ? renderTriggerAvatar(active, 22) : null}
310
+ <Text
311
+ numberOfLines={1}
312
+ style={
313
+ {
314
+ flex: variant === 'sidebar' ? 1 : undefined,
315
+ minWidth: 0,
316
+ flexShrink: 1,
317
+ fontSize: typeScale['heading']?.size ?? 15,
318
+ lineHeight: typeScale['heading']?.lineHeight ?? 20,
319
+ fontWeight: '600',
320
+ color: colors.text,
321
+ fontFamily: bodyFont,
322
+ } as TextStyle
323
+ }
324
+ >
325
+ {active?.name ?? 'Spaces'}
326
+ </Text>
327
+ {renderIcon ? renderIcon('chevron-down', 14, colors.textTertiary) : null}
328
+ </Pressable>
329
+
330
+ {renderContainer({ isOpen: open, onClose: close, anchorRef, children: dropdownContent })}
331
+ </>
332
+ );
333
+ }
334
+
335
+ // ── Internal helpers ──────────────────────────────────────────────────────────
336
+
337
+ interface SectionLabelProps {
338
+ label: string;
339
+ color: string;
340
+ size: number;
341
+ lineHeight: number;
342
+ font: string | undefined;
343
+ paddingH: number;
344
+ paddingV: number;
345
+ }
346
+
347
+ function SectionLabel({ label, color, size, lineHeight, font, paddingH, paddingV }: SectionLabelProps) {
348
+ return (
349
+ <Text
350
+ style={
351
+ {
352
+ fontSize: size,
353
+ lineHeight,
354
+ fontWeight: '600',
355
+ color,
356
+ fontFamily: font,
357
+ textTransform: 'uppercase',
358
+ letterSpacing: 0.5,
359
+ paddingHorizontal: paddingH,
360
+ paddingVertical: paddingV,
361
+ } as TextStyle
362
+ }
363
+ >
364
+ {label}
365
+ </Text>
366
+ );
367
+ }
368
+
369
+ interface SpaceRowProps {
370
+ space: SwitcherSpace;
371
+ active: boolean;
372
+ onPress: () => void;
373
+ renderAvatar?: (space: SwitcherSpace, size: number) => React.ReactNode;
374
+ renderIcon?: (name: SwitcherIconName, size: number, color: string) => React.ReactNode;
375
+ colors: ReturnType<typeof useOctoSpacesTheme>['colors'];
376
+ bodyFont: string | undefined;
377
+ bodySize: number;
378
+ bodyLine: number;
379
+ sp2: number;
380
+ sp3: number;
381
+ sp4: number;
382
+ radMd: number;
383
+ }
384
+
385
+ function SpaceRow({
386
+ space,
387
+ active,
388
+ onPress,
389
+ renderAvatar,
390
+ renderIcon,
391
+ colors,
392
+ bodyFont,
393
+ bodySize,
394
+ bodyLine,
395
+ sp2,
396
+ sp3,
397
+ sp4,
398
+ radMd,
399
+ }: SpaceRowProps) {
400
+ const [hovered, setHovered] = useState(false);
401
+
402
+ const bg = hovered
403
+ ? (colors.primarySubtle ?? 'rgba(0,0,0,0.04)')
404
+ : 'transparent';
405
+
406
+ return (
407
+ <Pressable
408
+ accessibilityRole="menuitem"
409
+ accessibilityLabel={active ? `${space.name} (current)` : `Switch to ${space.name}`}
410
+ accessibilityState={{ selected: active }}
411
+ onPress={onPress}
412
+ onMouseEnter={() => setHovered(true)}
413
+ onMouseLeave={() => setHovered(false)}
414
+ style={{
415
+ flexDirection: 'row',
416
+ alignItems: 'center',
417
+ gap: sp3,
418
+ paddingHorizontal: sp4,
419
+ paddingVertical: sp2,
420
+ borderRadius: radMd,
421
+ backgroundColor: bg,
422
+ }}
423
+ >
424
+ {renderAvatar ? renderAvatar(space, 24) : null}
425
+ <Text
426
+ numberOfLines={1}
427
+ style={
428
+ {
429
+ flex: 1,
430
+ minWidth: 0,
431
+ fontSize: bodySize,
432
+ lineHeight: bodyLine,
433
+ fontWeight: active ? '600' : '400',
434
+ color: active ? colors.primary : colors.text,
435
+ fontFamily: bodyFont,
436
+ } as TextStyle
437
+ }
438
+ >
439
+ {space.name}
440
+ </Text>
441
+ {active && renderIcon ? renderIcon('check', 15, colors.primary) : null}
442
+ </Pressable>
443
+ );
444
+ }
445
+
446
+ interface ActionRowProps {
447
+ label: string;
448
+ iconName: SwitcherIconName;
449
+ onPress: () => void;
450
+ renderIcon?: (name: SwitcherIconName, size: number, color: string) => React.ReactNode;
451
+ colors: ReturnType<typeof useOctoSpacesTheme>['colors'];
452
+ bodyFont: string | undefined;
453
+ bodySize: number;
454
+ bodyLine: number;
455
+ sp2: number;
456
+ sp3: number;
457
+ sp4: number;
458
+ radMd: number;
459
+ }
460
+
461
+ function ActionRow({
462
+ label,
463
+ iconName,
464
+ onPress,
465
+ renderIcon,
466
+ colors,
467
+ bodyFont,
468
+ bodySize,
469
+ bodyLine,
470
+ sp2,
471
+ sp3,
472
+ sp4,
473
+ radMd,
474
+ }: ActionRowProps) {
475
+ const [hovered, setHovered] = useState(false);
476
+
477
+ const bg = hovered
478
+ ? (colors.primarySubtle ?? 'rgba(0,0,0,0.04)')
479
+ : 'transparent';
480
+
481
+ return (
482
+ <Pressable
483
+ accessibilityRole="menuitem"
484
+ accessibilityLabel={label}
485
+ onPress={onPress}
486
+ onMouseEnter={() => setHovered(true)}
487
+ onMouseLeave={() => setHovered(false)}
488
+ style={{
489
+ flexDirection: 'row',
490
+ alignItems: 'center',
491
+ gap: sp3,
492
+ paddingHorizontal: sp4,
493
+ paddingVertical: sp2,
494
+ borderRadius: radMd,
495
+ backgroundColor: bg,
496
+ }}
497
+ >
498
+ {renderIcon ? (
499
+ <View style={{ width: 24, alignItems: 'center', justifyContent: 'center' }}>
500
+ {renderIcon(iconName, 15, colors.textSecondary)}
501
+ </View>
502
+ ) : null}
503
+ <Text
504
+ numberOfLines={1}
505
+ style={
506
+ {
507
+ flex: 1,
508
+ minWidth: 0,
509
+ fontSize: bodySize,
510
+ lineHeight: bodyLine,
511
+ fontWeight: '400',
512
+ color: colors.text,
513
+ fontFamily: bodyFont,
514
+ } as TextStyle
515
+ }
516
+ >
517
+ {label}
518
+ </Text>
519
+ </Pressable>
520
+ );
521
+ }
@@ -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';