@drakkar.software/octospaces-ui 0.2.1 → 0.4.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.
@@ -0,0 +1,590 @@
1
+ /**
2
+ * Headless, abstractly-themed vertical spaces rail.
3
+ *
4
+ * The component reads the injected {@link Theme} via `useOctoSpacesTheme()` and
5
+ * delegates all app-specific concerns to props:
6
+ *
7
+ * - Icons are rendered by `renderIcon` (keeps `@expo/vector-icons` out of this package).
8
+ * - Space tile images are rendered by `renderTileImage` (keeps `expo-image` out too).
9
+ * - Unread badges are rendered by `renderBadge`.
10
+ * - The rail foot (account avatar + menu) is rendered by `renderFoot`.
11
+ * - Web drag-reorder is wired via the `useTileDnd` hook prop (see below).
12
+ *
13
+ * All React Native primitives used here ship with the `react-native` peer dep.
14
+ */
15
+ import React, { useState } from 'react';
16
+ import {
17
+ Pressable as RNPressable,
18
+ ScrollView,
19
+ Text,
20
+ View,
21
+ } from 'react-native';
22
+ import type { PressableProps, View as RNView } from 'react-native';
23
+
24
+ import { useOctoSpacesTheme } from '../theme/provider.js';
25
+ import { railTileState } from './tile-state.js';
26
+ import type { RailTileTokens } from './tile-state.js';
27
+ import type { RailIconName, RailSpace } from './types.js';
28
+
29
+ // ── Pressable with web hover events ───────────────────────────────────────────
30
+
31
+ // React Native Web supports onMouseEnter/onMouseLeave for hover detection.
32
+ // The peer dep is >=0.75 which includes these events in the ViewProps contract.
33
+ // Cast to ForwardRefExoticComponent so `ref` is a valid JSX prop (Pressable
34
+ // already uses forwardRef internally; this just makes TypeScript aware of it).
35
+ type HoverProps = { onMouseEnter?: () => void; onMouseLeave?: () => void };
36
+ const Pressable = RNPressable as React.ForwardRefExoticComponent<
37
+ PressableProps & HoverProps & React.RefAttributes<RNView>
38
+ >;
39
+
40
+ // ── Props ─────────────────────────────────────────────────────────────────────
41
+
42
+ export interface SpacesRailProps {
43
+ /** The spaces to show in the scrollable column. */
44
+ spaces: RailSpace[];
45
+ /** The currently-active space id (or null / undefined for none). */
46
+ activeId?: string | null;
47
+ /** Called when the user selects a space tile. */
48
+ onSelect?: (id: string) => void;
49
+ /** Called when the user taps the "add" tile. */
50
+ onAdd?: () => void;
51
+ /** When provided, renders a leading DM-home tile. */
52
+ onSelectDms?: () => void;
53
+ /** Whether the DM-home tile is the active selection. */
54
+ dmsActive?: boolean;
55
+ /** Unread count for the DM-home tile badge. */
56
+ dmUnread?: number;
57
+ /** Accessibility label for the DM-home tile (default: "Direct messages"). */
58
+ dmLabel?: string;
59
+ /** Accessibility label for the add-space tile (default: "Create or join a space"). */
60
+ addLabel?: string;
61
+ /**
62
+ * Render a named icon at the given size and color. Used for the DM tile icon
63
+ * (`'dm'`), the E2EE lock corner (`'lock'`), the mute corner (`'mute'`),
64
+ * and the add tile icon (`'add'`). Return `null` to suppress the icon slot.
65
+ * If omitted, all icon slots render nothing.
66
+ */
67
+ renderIcon?: (name: RailIconName, size: number, color: string) => React.ReactNode;
68
+ /**
69
+ * Render an image filling the tile background. Only called when `space.image`
70
+ * is set. The component must fill its parent (`StyleSheet.absoluteFill` or
71
+ * equivalent). If omitted, the short-name monogram is shown instead.
72
+ */
73
+ renderTileImage?: (space: RailSpace) => React.ReactNode;
74
+ /**
75
+ * Render an unread badge. Only called when `space.unread > 0`.
76
+ * If omitted, badges are not shown.
77
+ */
78
+ renderBadge?: (count: number) => React.ReactNode;
79
+ /**
80
+ * When `true`, each tile shows a small E2EE-lock corner badge (bottom-right).
81
+ * Requires `renderIcon` to be provided (otherwise the corner renders nothing).
82
+ * Default: `false`.
83
+ */
84
+ showLockCorner?: boolean;
85
+ /**
86
+ * Render the pinned rail foot (e.g. the account avatar and popover).
87
+ * The host app owns this entirely — identity state stays out of the package.
88
+ */
89
+ renderFoot?: () => React.ReactNode;
90
+ /**
91
+ * **Hook injection for web drag-reorder.** When provided, each space tile is
92
+ * wrapped in a `DndTile` that calls `useTileDnd(spaceId)` unconditionally at
93
+ * the top of its render — treat this prop as a React hook and keep it stable
94
+ * for the lifetime of a `SpacesRail` mount (always provided or always absent).
95
+ * Omit on native / in apps that don't need DnD.
96
+ */
97
+ useTileDnd?: (spaceId: string) => { ref?: React.Ref<RNView>; over?: boolean };
98
+ }
99
+
100
+ // ── Token resolver ─────────────────────────────────────────────────────────────
101
+
102
+ function resolveRailTokens(theme: ReturnType<typeof useOctoSpacesTheme>): RailTileTokens {
103
+ const { colors, swatches } = theme;
104
+ return {
105
+ primary: colors.primary,
106
+ primaryMuted: colors.primaryMuted,
107
+ primarySubtle: colors.primarySubtle,
108
+ surfaceInput: colors.surfaceInput,
109
+ borderSubtle: colors.borderSubtle,
110
+ textOnPrimary: colors.textOnPrimary,
111
+ textSecondary: colors.textSecondary,
112
+ textTertiary: colors.textTertiary,
113
+ railTile: swatches['railTile'] ?? colors.surfaceInput,
114
+ railTileHoverBorder: swatches['railTileHoverBorder'] ?? colors.primarySubtle,
115
+ railGlow: swatches['railGlow'] ?? colors.primary,
116
+ railTileHoverInk: swatches['railTileHoverInk'] ?? colors.primary,
117
+ };
118
+ }
119
+
120
+ // ── Shared tile dimensions (constant) ────────────────────────────────────────
121
+
122
+ const TILE_SIZE = 40;
123
+ const CORNER_SIZE = 16;
124
+ const BADGE_OFFSET = -5;
125
+ const CORNER_OFFSET = -3;
126
+
127
+ // ── Tile content (non-hook render helper) ─────────────────────────────────────
128
+
129
+ interface TileContentProps {
130
+ space: RailSpace;
131
+ labelColor: string;
132
+ fontFamily?: string;
133
+ fontSize: number;
134
+ lineHeight: number;
135
+ cornerBg: string;
136
+ cornerBorder: string;
137
+ cornerIconColor: string;
138
+ renderIcon?: SpacesRailProps['renderIcon'];
139
+ renderTileImage?: SpacesRailProps['renderTileImage'];
140
+ renderBadge?: SpacesRailProps['renderBadge'];
141
+ showLockCorner?: boolean;
142
+ }
143
+
144
+ function TileContent({
145
+ space,
146
+ labelColor,
147
+ fontFamily,
148
+ fontSize,
149
+ lineHeight,
150
+ cornerBg,
151
+ cornerBorder,
152
+ cornerIconColor,
153
+ renderIcon,
154
+ renderTileImage,
155
+ renderBadge,
156
+ showLockCorner,
157
+ }: TileContentProps) {
158
+ return (
159
+ <>
160
+ {/* Image or monogram */}
161
+ {space.image && renderTileImage ? (
162
+ renderTileImage(space)
163
+ ) : (
164
+ <Text
165
+ style={{
166
+ fontSize,
167
+ lineHeight,
168
+ fontWeight: '700',
169
+ fontFamily: fontFamily ?? undefined,
170
+ color: labelColor,
171
+ }}
172
+ numberOfLines={1}
173
+ >
174
+ {space.short}
175
+ </Text>
176
+ )}
177
+ {/* E2EE lock corner (bottom-right) */}
178
+ {showLockCorner && renderIcon ? (
179
+ <View
180
+ style={{
181
+ position: 'absolute',
182
+ bottom: CORNER_OFFSET,
183
+ right: CORNER_OFFSET,
184
+ width: CORNER_SIZE,
185
+ height: CORNER_SIZE,
186
+ borderRadius: CORNER_SIZE / 2,
187
+ borderWidth: 1,
188
+ alignItems: 'center',
189
+ justifyContent: 'center',
190
+ backgroundColor: cornerBg,
191
+ borderColor: cornerBorder,
192
+ }}
193
+ >
194
+ {renderIcon('lock', 9, cornerIconColor)}
195
+ </View>
196
+ ) : null}
197
+ {/* Mute corner (bottom-left) */}
198
+ {space.muted && renderIcon ? (
199
+ <View
200
+ style={{
201
+ position: 'absolute',
202
+ bottom: CORNER_OFFSET,
203
+ left: CORNER_OFFSET,
204
+ width: CORNER_SIZE,
205
+ height: CORNER_SIZE,
206
+ borderRadius: CORNER_SIZE / 2,
207
+ borderWidth: 1,
208
+ alignItems: 'center',
209
+ justifyContent: 'center',
210
+ backgroundColor: cornerBg,
211
+ borderColor: cornerBorder,
212
+ }}
213
+ >
214
+ {renderIcon('mute', 9, cornerIconColor)}
215
+ </View>
216
+ ) : null}
217
+ {/* Unread badge (top-right) */}
218
+ {space.unread ? (
219
+ <View
220
+ style={{
221
+ position: 'absolute',
222
+ top: BADGE_OFFSET,
223
+ right: BADGE_OFFSET,
224
+ }}
225
+ >
226
+ {renderBadge ? renderBadge(space.unread) : null}
227
+ </View>
228
+ ) : null}
229
+ </>
230
+ );
231
+ }
232
+
233
+ // ── PlainTile — space tile without DnD ────────────────────────────────────────
234
+
235
+ interface TileSharedProps {
236
+ space: RailSpace;
237
+ active: boolean;
238
+ onPress?: () => void;
239
+ tokens: RailTileTokens;
240
+ radiusActive: number;
241
+ radiusDefault: number;
242
+ renderIcon?: SpacesRailProps['renderIcon'];
243
+ renderTileImage?: SpacesRailProps['renderTileImage'];
244
+ renderBadge?: SpacesRailProps['renderBadge'];
245
+ showLockCorner?: boolean;
246
+ cornerBg: string;
247
+ cornerBorder: string;
248
+ fontFamily?: string;
249
+ fontSize: number;
250
+ lineHeight: number;
251
+ }
252
+
253
+ function PlainTile({
254
+ space,
255
+ active,
256
+ onPress,
257
+ tokens,
258
+ radiusActive,
259
+ radiusDefault,
260
+ renderIcon,
261
+ renderTileImage,
262
+ renderBadge,
263
+ showLockCorner,
264
+ cornerBg,
265
+ cornerBorder,
266
+ fontFamily,
267
+ fontSize,
268
+ lineHeight,
269
+ }: TileSharedProps) {
270
+ const [hovered, setHovered] = useState(false);
271
+ const s = railTileState({ active, hovered, over: false }, tokens, radiusActive, radiusDefault);
272
+
273
+ return (
274
+ <Pressable
275
+ onPress={onPress}
276
+ onMouseEnter={() => setHovered(true)}
277
+ onMouseLeave={() => setHovered(false)}
278
+ accessibilityRole="button"
279
+ accessibilityLabel={space.short}
280
+ style={{
281
+ position: 'relative',
282
+ width: TILE_SIZE,
283
+ height: TILE_SIZE,
284
+ alignItems: 'center',
285
+ justifyContent: 'center',
286
+ overflow: 'hidden',
287
+ borderRadius: s.radius,
288
+ backgroundColor: s.bg,
289
+ borderWidth: s.borderWidth,
290
+ borderColor: s.borderColor,
291
+ ...(s.shadow ?? {}),
292
+ }}
293
+ >
294
+ <TileContent
295
+ space={space}
296
+ labelColor={s.labelColor}
297
+ fontFamily={fontFamily}
298
+ fontSize={fontSize}
299
+ lineHeight={lineHeight}
300
+ cornerBg={cornerBg}
301
+ cornerBorder={cornerBorder}
302
+ cornerIconColor={tokens.textTertiary}
303
+ renderIcon={renderIcon}
304
+ renderTileImage={renderTileImage}
305
+ renderBadge={renderBadge}
306
+ showLockCorner={showLockCorner}
307
+ />
308
+ </Pressable>
309
+ );
310
+ }
311
+
312
+ // ── DndTile — space tile with hook-injected DnD ref + over state ──────────────
313
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
314
+
315
+ interface DndTileProps extends TileSharedProps {
316
+ /** Hook injection: called unconditionally at the top of this component.
317
+ * Treat it as a React hook — always provided for every DndTile. */
318
+ dnd: NonNullable<SpacesRailProps['useTileDnd']>;
319
+ }
320
+
321
+ function DndTile({
322
+ space,
323
+ active,
324
+ onPress,
325
+ tokens,
326
+ radiusActive,
327
+ radiusDefault,
328
+ renderIcon,
329
+ renderTileImage,
330
+ renderBadge,
331
+ showLockCorner,
332
+ cornerBg,
333
+ cornerBorder,
334
+ fontFamily,
335
+ fontSize,
336
+ lineHeight,
337
+ dnd,
338
+ }: DndTileProps) {
339
+ const [hovered, setHovered] = useState(false);
340
+ // Hook injection: useTileDnd is called unconditionally here (it IS a hook).
341
+ // eslint-disable-next-line react-hooks/rules-of-hooks
342
+ const { ref, over = false } = dnd(space.id);
343
+ const s = railTileState({ active, hovered, over }, tokens, radiusActive, radiusDefault);
344
+
345
+ return (
346
+ <Pressable
347
+ ref={ref as React.Ref<RNView>}
348
+ onPress={onPress}
349
+ onMouseEnter={() => setHovered(true)}
350
+ onMouseLeave={() => setHovered(false)}
351
+ accessibilityRole="button"
352
+ accessibilityLabel={space.short}
353
+ style={{
354
+ position: 'relative',
355
+ width: TILE_SIZE,
356
+ height: TILE_SIZE,
357
+ alignItems: 'center',
358
+ justifyContent: 'center',
359
+ overflow: 'hidden',
360
+ borderRadius: s.radius,
361
+ backgroundColor: s.bg,
362
+ borderWidth: s.borderWidth,
363
+ borderColor: s.borderColor,
364
+ ...(s.shadow ?? {}),
365
+ }}
366
+ >
367
+ <TileContent
368
+ space={space}
369
+ labelColor={s.labelColor}
370
+ fontFamily={fontFamily}
371
+ fontSize={fontSize}
372
+ lineHeight={lineHeight}
373
+ cornerBg={cornerBg}
374
+ cornerBorder={cornerBorder}
375
+ cornerIconColor={tokens.textTertiary}
376
+ renderIcon={renderIcon}
377
+ renderTileImage={renderTileImage}
378
+ renderBadge={renderBadge}
379
+ showLockCorner={showLockCorner}
380
+ />
381
+ </Pressable>
382
+ );
383
+ }
384
+
385
+ // ── SpacesRail ─────────────────────────────────────────────────────────────────
386
+
387
+ /**
388
+ * Vertical spaces rail — a 64px-wide column of square space tiles, a DM-home tile,
389
+ * an add-space tile, and a pinned foot for the account widget.
390
+ *
391
+ * Styled entirely from the injected {@link Theme} via `useOctoSpacesTheme()`.
392
+ * All icons, images, badges, and the account foot are provided by the host app.
393
+ */
394
+ export function SpacesRail({
395
+ spaces,
396
+ activeId,
397
+ onSelect,
398
+ onAdd,
399
+ onSelectDms,
400
+ dmsActive = false,
401
+ dmUnread,
402
+ dmLabel = 'Direct messages',
403
+ addLabel = 'Create or join a space',
404
+ renderIcon,
405
+ renderTileImage,
406
+ renderBadge,
407
+ showLockCorner = false,
408
+ renderFoot,
409
+ useTileDnd,
410
+ }: SpacesRailProps) {
411
+ const theme = useOctoSpacesTheme();
412
+ const { colors, spacing, radii, type: typeScale, fonts, layout } = theme;
413
+
414
+ const tokens = resolveRailTokens(theme);
415
+
416
+ // Layout constants with fallbacks for hosts that haven't set them.
417
+ const railWidth = (layout['railWidth'] as number | undefined) ?? 64;
418
+ const spaceV = (spacing['2'] as number | undefined) ?? 8;
419
+ const spaceXs = (spacing['1'] as number | undefined) ?? 4;
420
+ const spaceS = (spacing['2'] as number | undefined) ?? 8;
421
+ const spaceMd = (spacing['3'] as number | undefined) ?? 12;
422
+
423
+ const radiusActive = (radii['lg'] as number | undefined) ?? 12;
424
+ const radiusDefault = (radii['xl'] as number | undefined) ?? 16;
425
+
426
+ const footnoteSize = typeScale['footnote']?.size ?? 12;
427
+ const footnoteLineH = typeScale['footnote']?.lineHeight ?? 18;
428
+ const monoFont = fonts['mono'] ?? undefined;
429
+
430
+ // Corner-badge tokens (background = rail surface, border = rail border).
431
+ const cornerBg = colors.sidebar;
432
+ const cornerBorder = colors.border;
433
+
434
+ // Shared tile props (passed to every tile variant).
435
+ const tileShared = {
436
+ tokens,
437
+ radiusActive,
438
+ radiusDefault,
439
+ renderIcon,
440
+ renderTileImage,
441
+ renderBadge,
442
+ showLockCorner,
443
+ cornerBg,
444
+ cornerBorder,
445
+ fontFamily: monoFont,
446
+ fontSize: footnoteSize,
447
+ lineHeight: footnoteLineH,
448
+ };
449
+
450
+ // DM tile hover state (managed here since DM tile is inline, not a separate component).
451
+ const [dmHovered, setDmHovered] = useState(false);
452
+
453
+ const dmTileStyle = railTileState(
454
+ { active: dmsActive, hovered: dmHovered, over: false },
455
+ tokens,
456
+ radiusActive,
457
+ radiusDefault,
458
+ );
459
+
460
+ const dmIconColor = dmsActive ? colors.textOnPrimary : dmHovered ? tokens.railTileHoverInk : colors.textSecondary;
461
+
462
+ // Add-tile hover state.
463
+ const [addHovered, setAddHovered] = useState(false);
464
+
465
+ // Whether DnD is active (determines which tile variant to render).
466
+ const hasDnd = !!useTileDnd;
467
+
468
+ return (
469
+ <View
470
+ style={{
471
+ width: railWidth,
472
+ paddingVertical: spaceMd,
473
+ borderRightWidth: 1,
474
+ borderRightColor: colors.border,
475
+ backgroundColor: colors.sidebar,
476
+ alignItems: 'center',
477
+ gap: spaceS,
478
+ }}
479
+ >
480
+ {/* Scrollable tile column */}
481
+ <ScrollView
482
+ style={{ alignSelf: 'stretch', flex: 1 }}
483
+ contentContainerStyle={{
484
+ alignItems: 'center',
485
+ gap: spaceV,
486
+ paddingVertical: spaceXs,
487
+ }}
488
+ showsVerticalScrollIndicator={false}
489
+ >
490
+ {/* DM-home tile (pinned first when provided) */}
491
+ {onSelectDms ? (
492
+ <View style={{ position: 'relative' }}>
493
+ <Pressable
494
+ onPress={onSelectDms}
495
+ onMouseEnter={() => setDmHovered(true)}
496
+ onMouseLeave={() => setDmHovered(false)}
497
+ accessibilityRole="button"
498
+ accessibilityLabel={dmLabel}
499
+ style={{
500
+ width: TILE_SIZE,
501
+ height: TILE_SIZE,
502
+ alignItems: 'center',
503
+ justifyContent: 'center',
504
+ borderRadius: dmTileStyle.radius,
505
+ backgroundColor: dmTileStyle.bg,
506
+ borderWidth: dmTileStyle.borderWidth,
507
+ borderColor: dmTileStyle.borderColor,
508
+ ...(dmTileStyle.shadow ?? {}),
509
+ }}
510
+ >
511
+ {renderIcon ? renderIcon('dm', 20, dmIconColor) : null}
512
+ </Pressable>
513
+ {/* Lock corner */}
514
+ {showLockCorner && renderIcon ? (
515
+ <View
516
+ style={{
517
+ position: 'absolute',
518
+ bottom: CORNER_OFFSET,
519
+ right: CORNER_OFFSET,
520
+ width: CORNER_SIZE,
521
+ height: CORNER_SIZE,
522
+ borderRadius: CORNER_SIZE / 2,
523
+ borderWidth: 1,
524
+ alignItems: 'center',
525
+ justifyContent: 'center',
526
+ backgroundColor: cornerBg,
527
+ borderColor: cornerBorder,
528
+ }}
529
+ >
530
+ {renderIcon('lock', 9, tokens.textTertiary)}
531
+ </View>
532
+ ) : null}
533
+ {/* DM unread badge */}
534
+ {dmUnread ? (
535
+ <View style={{ position: 'absolute', top: BADGE_OFFSET, right: BADGE_OFFSET }}>
536
+ {renderBadge ? renderBadge(dmUnread) : null}
537
+ </View>
538
+ ) : null}
539
+ </View>
540
+ ) : null}
541
+
542
+ {/* Space tiles */}
543
+ {spaces.map((s) =>
544
+ hasDnd ? (
545
+ <DndTile
546
+ key={s.id}
547
+ space={s}
548
+ active={s.id === activeId}
549
+ onPress={() => onSelect?.(s.id)}
550
+ dnd={useTileDnd!}
551
+ {...tileShared}
552
+ />
553
+ ) : (
554
+ <PlainTile
555
+ key={s.id}
556
+ space={s}
557
+ active={s.id === activeId}
558
+ onPress={() => onSelect?.(s.id)}
559
+ {...tileShared}
560
+ />
561
+ ),
562
+ )}
563
+
564
+ {/* Add-space tile */}
565
+ <Pressable
566
+ onPress={onAdd}
567
+ onMouseEnter={() => setAddHovered(true)}
568
+ onMouseLeave={() => setAddHovered(false)}
569
+ accessibilityRole="button"
570
+ accessibilityLabel={addLabel}
571
+ style={{
572
+ width: TILE_SIZE,
573
+ height: TILE_SIZE,
574
+ alignItems: 'center',
575
+ justifyContent: 'center',
576
+ borderRadius: radiusDefault,
577
+ borderWidth: 1,
578
+ borderStyle: 'dashed',
579
+ borderColor: addHovered ? colors.border : colors.borderSubtle,
580
+ }}
581
+ >
582
+ {renderIcon ? renderIcon('add', 16, addHovered ? tokens.railTileHoverInk : colors.textTertiary) : null}
583
+ </Pressable>
584
+ </ScrollView>
585
+
586
+ {/* Pinned foot — account avatar, popover, etc. */}
587
+ {renderFoot ? renderFoot() : null}
588
+ </View>
589
+ );
590
+ }
@@ -0,0 +1,11 @@
1
+ export type { RailSpace, RailIconName } from './types.js';
2
+ export type { SpacesRailProps } from './SpacesRail.js';
3
+ export { SpacesRail } from './SpacesRail.js';
4
+ export type { SidebarProps } from './Sidebar.js';
5
+ export { Sidebar } from './Sidebar.js';
6
+ export type { SidebarHeaderProps } from './SidebarHeader.js';
7
+ export { SidebarHeader } from './SidebarHeader.js';
8
+ export type { SidebarActionButtonProps } from './SidebarActionButton.js';
9
+ export { SidebarActionButton } from './SidebarActionButton.js';
10
+ export type { SidebarItemProps } from './SidebarItem.js';
11
+ export { SidebarItem } from './SidebarItem.js';