@drakkar.software/octospaces-ui 0.3.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.
- package/dist/index.d.ts +354 -3
- package/dist/index.js +588 -0
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +18 -1
- package/src/lightbox/Lightbox.tsx +132 -0
- package/src/sidebar/Sidebar.tsx +101 -0
- package/src/sidebar/SidebarActionButton.tsx +86 -0
- package/src/sidebar/SidebarHeader.tsx +111 -0
- package/src/sidebar/SidebarItem.tsx +148 -0
- package/src/sidebar/SpaceSwitcher.tsx +521 -0
- package/src/sidebar/index.ts +13 -0
- package/src/theme/helpers.test.ts +1 -0
- package/src/theme/types.ts +1 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Headless themed sidebar panel shell.
|
|
3
|
+
*
|
|
4
|
+
* Renders the 240–248px wide panel: a background surface (colors.sidebarPanel),
|
|
5
|
+
* a right border (colors.border), an optional header slot, the item list
|
|
6
|
+
* (scrollable by default), and an optional footer slot.
|
|
7
|
+
*
|
|
8
|
+
* All content is delegated to the host via slots — only the chrome (bg, border,
|
|
9
|
+
* width) and the ScrollView wrapper are shared.
|
|
10
|
+
*
|
|
11
|
+
* ```tsx
|
|
12
|
+
* <Sidebar
|
|
13
|
+
* header={
|
|
14
|
+
* <SidebarHeader
|
|
15
|
+
* leading={<SpaceSwitcher variant="sidebar" />}
|
|
16
|
+
* actions={<>
|
|
17
|
+
* <IconButton name="search" ... />
|
|
18
|
+
* <IconButton name="plus" ... />
|
|
19
|
+
* </>}
|
|
20
|
+
* />
|
|
21
|
+
* }
|
|
22
|
+
* contentContainerStyle={{ paddingHorizontal: 8 }}
|
|
23
|
+
* >
|
|
24
|
+
* <WorkObjects ... />
|
|
25
|
+
* </Sidebar>
|
|
26
|
+
* ```
|
|
27
|
+
*/
|
|
28
|
+
import React from 'react';
|
|
29
|
+
import { ScrollView, View } from 'react-native';
|
|
30
|
+
import type { StyleProp, ViewStyle } from 'react-native';
|
|
31
|
+
|
|
32
|
+
import { useOctoSpacesTheme } from '../theme/provider.js';
|
|
33
|
+
|
|
34
|
+
export interface SidebarProps {
|
|
35
|
+
/** Header slot — render a {@link SidebarHeader} or custom content above the list. */
|
|
36
|
+
header?: React.ReactNode;
|
|
37
|
+
/** Footer slot — pinned below the scroll area. */
|
|
38
|
+
footer?: React.ReactNode;
|
|
39
|
+
/** The item list. Wrapped in a ScrollView unless `scrollable` is `false`. */
|
|
40
|
+
children: React.ReactNode;
|
|
41
|
+
/** Panel width in pixels. Defaults to `theme.layout.sidebarWidth ?? 248`. */
|
|
42
|
+
width?: number;
|
|
43
|
+
/**
|
|
44
|
+
* When `false`, children are rendered in a plain `View` instead of a `ScrollView`.
|
|
45
|
+
* Use when the host manages its own scroll (e.g. multiple independent lists).
|
|
46
|
+
* @default true
|
|
47
|
+
*/
|
|
48
|
+
scrollable?: boolean;
|
|
49
|
+
/**
|
|
50
|
+
* Passed to the `ScrollView`'s `contentContainerStyle` (or to the body `View`
|
|
51
|
+
* style when `scrollable` is `false`).
|
|
52
|
+
*/
|
|
53
|
+
contentContainerStyle?: StyleProp<ViewStyle>;
|
|
54
|
+
/**
|
|
55
|
+
* Override the panel background color.
|
|
56
|
+
* Defaults to `colors.sidebarPanel` from the injected theme.
|
|
57
|
+
*/
|
|
58
|
+
background?: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function Sidebar({
|
|
62
|
+
header,
|
|
63
|
+
footer,
|
|
64
|
+
children,
|
|
65
|
+
width,
|
|
66
|
+
scrollable = true,
|
|
67
|
+
contentContainerStyle,
|
|
68
|
+
background,
|
|
69
|
+
}: SidebarProps) {
|
|
70
|
+
const theme = useOctoSpacesTheme();
|
|
71
|
+
const { colors, layout } = theme;
|
|
72
|
+
|
|
73
|
+
const panelWidth = width ?? (layout['sidebarWidth'] as number | undefined) ?? 248;
|
|
74
|
+
const bg = background ?? colors.sidebarPanel;
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
<View
|
|
78
|
+
style={{
|
|
79
|
+
width: panelWidth,
|
|
80
|
+
backgroundColor: bg,
|
|
81
|
+
borderRightWidth: 1,
|
|
82
|
+
borderRightColor: colors.border,
|
|
83
|
+
flexDirection: 'column',
|
|
84
|
+
}}
|
|
85
|
+
>
|
|
86
|
+
{header ?? null}
|
|
87
|
+
{scrollable ? (
|
|
88
|
+
<ScrollView
|
|
89
|
+
style={{ flex: 1 }}
|
|
90
|
+
contentContainerStyle={contentContainerStyle ?? undefined}
|
|
91
|
+
showsVerticalScrollIndicator={false}
|
|
92
|
+
>
|
|
93
|
+
{children}
|
|
94
|
+
</ScrollView>
|
|
95
|
+
) : (
|
|
96
|
+
<View style={[{ flex: 1 }, contentContainerStyle]}>{children}</View>
|
|
97
|
+
)}
|
|
98
|
+
{footer ?? null}
|
|
99
|
+
</View>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Headless themed icon-button primitive for sidebar action slots.
|
|
3
|
+
*
|
|
4
|
+
* Renders a square `Pressable` with hover/press wash from the injected theme.
|
|
5
|
+
* The icon is provided by the host app as a `ReactNode` — this keeps the package
|
|
6
|
+
* free of `@expo/vector-icons`, `Tooltip`, and keyboard-shortcut concerns.
|
|
7
|
+
*
|
|
8
|
+
* Host apps with richer icon buttons (OctoVault's `IconButton` has tooltips,
|
|
9
|
+
* keyboard-shortcut labels, and haptics) can slot those directly into
|
|
10
|
+
* `SidebarHeader.actions` instead — this primitive is for simpler headless
|
|
11
|
+
* consumers.
|
|
12
|
+
*
|
|
13
|
+
* ```tsx
|
|
14
|
+
* <SidebarActionButton
|
|
15
|
+
* icon={<Icon name="search" size={15} color={theme.colors.textSecondary} />}
|
|
16
|
+
* onPress={openSearch}
|
|
17
|
+
* accessibilityLabel="Search"
|
|
18
|
+
* />
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
import React, { useState } from 'react';
|
|
22
|
+
import { Pressable as RNPressable } from 'react-native';
|
|
23
|
+
import type { PressableProps, View as RNView } from 'react-native';
|
|
24
|
+
|
|
25
|
+
import { useOctoSpacesTheme } from '../theme/provider.js';
|
|
26
|
+
|
|
27
|
+
// React Native Web supports onMouseEnter/onMouseLeave.
|
|
28
|
+
type HoverProps = { onMouseEnter?: () => void; onMouseLeave?: () => void };
|
|
29
|
+
const Pressable = RNPressable as React.ForwardRefExoticComponent<
|
|
30
|
+
PressableProps & HoverProps & React.RefAttributes<RNView>
|
|
31
|
+
>;
|
|
32
|
+
|
|
33
|
+
export interface SidebarActionButtonProps {
|
|
34
|
+
/** Icon element to render — the host provides the icon component, size, and color. */
|
|
35
|
+
icon: React.ReactNode;
|
|
36
|
+
onPress: () => void;
|
|
37
|
+
accessibilityLabel: string;
|
|
38
|
+
/**
|
|
39
|
+
* Width and height of the pressable target in pixels.
|
|
40
|
+
* @default 32
|
|
41
|
+
*/
|
|
42
|
+
size?: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function SidebarActionButton({
|
|
46
|
+
icon,
|
|
47
|
+
onPress,
|
|
48
|
+
accessibilityLabel,
|
|
49
|
+
size = 32,
|
|
50
|
+
}: SidebarActionButtonProps) {
|
|
51
|
+
const theme = useOctoSpacesTheme();
|
|
52
|
+
const { colors, radii } = theme;
|
|
53
|
+
|
|
54
|
+
const [hovered, setHovered] = useState(false);
|
|
55
|
+
const [pressed, setPressed] = useState(false);
|
|
56
|
+
|
|
57
|
+
const bg = pressed
|
|
58
|
+
? (colors.primaryMuted ?? 'rgba(0,0,0,0.10)')
|
|
59
|
+
: hovered
|
|
60
|
+
? (colors.primarySubtle ?? 'rgba(0,0,0,0.05)')
|
|
61
|
+
: 'transparent';
|
|
62
|
+
|
|
63
|
+
const radius = (radii['sm'] as number | undefined) ?? 4;
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<Pressable
|
|
67
|
+
onPress={onPress}
|
|
68
|
+
onPressIn={() => setPressed(true)}
|
|
69
|
+
onPressOut={() => setPressed(false)}
|
|
70
|
+
onMouseEnter={() => setHovered(true)}
|
|
71
|
+
onMouseLeave={() => setHovered(false)}
|
|
72
|
+
accessibilityRole="button"
|
|
73
|
+
accessibilityLabel={accessibilityLabel}
|
|
74
|
+
style={{
|
|
75
|
+
width: size,
|
|
76
|
+
height: size,
|
|
77
|
+
alignItems: 'center',
|
|
78
|
+
justifyContent: 'center',
|
|
79
|
+
borderRadius: radius,
|
|
80
|
+
backgroundColor: bg,
|
|
81
|
+
}}
|
|
82
|
+
>
|
|
83
|
+
{icon}
|
|
84
|
+
</Pressable>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Headless themed sidebar header strip.
|
|
3
|
+
*
|
|
4
|
+
* Row 1: a `leading` slot (flex:1, typically a space selector or name) + an
|
|
5
|
+
* optional `actions` row (right-aligned command buttons). An optional `extra`
|
|
6
|
+
* slot below row 1 accepts additional controls (OctoChat uses this for its
|
|
7
|
+
* `ModeSwitcher` + jump-to search bar). An optional bottom hairline divider.
|
|
8
|
+
*
|
|
9
|
+
* ```tsx
|
|
10
|
+
* // OctoVault — space switcher + command icons
|
|
11
|
+
* <SidebarHeader
|
|
12
|
+
* leading={<SpaceSwitcher variant="sidebar" />}
|
|
13
|
+
* actions={<>
|
|
14
|
+
* <IconButton name="search" onPress={openSearch} tooltip="Search" shortcut="⌘K" />
|
|
15
|
+
* <IconButton name="plus" onPress={newPage} tooltip="New page" shortcut="⌘N" />
|
|
16
|
+
* <IconButton name="sidebar" onPress={collapse} tooltip="Hide sidebar" shortcut="⌘\\" />
|
|
17
|
+
* </>}
|
|
18
|
+
* />
|
|
19
|
+
*
|
|
20
|
+
* // OctoChat — space menu + mode switcher + jump-to bar
|
|
21
|
+
* <SidebarHeader
|
|
22
|
+
* leading={<Pressable onPress={onOpenSpaceMenu}>…</Pressable>}
|
|
23
|
+
* extra={<><ModeSwitcher /><JumpToBar /></>}
|
|
24
|
+
* divider
|
|
25
|
+
* />
|
|
26
|
+
* ```
|
|
27
|
+
*/
|
|
28
|
+
import React from 'react';
|
|
29
|
+
import { StyleSheet, View } from 'react-native';
|
|
30
|
+
import type { StyleProp, ViewStyle } from 'react-native';
|
|
31
|
+
|
|
32
|
+
import { useOctoSpacesTheme } from '../theme/provider.js';
|
|
33
|
+
|
|
34
|
+
export interface SidebarHeaderProps {
|
|
35
|
+
/** Leading content (flex:1) — typically the space selector / space name. */
|
|
36
|
+
leading: React.ReactNode;
|
|
37
|
+
/**
|
|
38
|
+
* Right-aligned action buttons row.
|
|
39
|
+
* OctoVault passes its own `IconButton` components here (with tooltips +
|
|
40
|
+
* keyboard shortcuts). For simpler headless consumers use `SidebarActionButton`.
|
|
41
|
+
*/
|
|
42
|
+
actions?: React.ReactNode;
|
|
43
|
+
/**
|
|
44
|
+
* Extra slot rendered below the leading+actions row.
|
|
45
|
+
* Use for secondary controls like a mode switcher or a search bar.
|
|
46
|
+
*/
|
|
47
|
+
extra?: React.ReactNode;
|
|
48
|
+
/**
|
|
49
|
+
* Render a hairline bottom divider in `colors.borderSubtle`.
|
|
50
|
+
* @default false
|
|
51
|
+
*/
|
|
52
|
+
divider?: boolean;
|
|
53
|
+
/**
|
|
54
|
+
* Style applied to the outer container. Use to set host-specific padding,
|
|
55
|
+
* gap, background override, etc.
|
|
56
|
+
*/
|
|
57
|
+
style?: StyleProp<ViewStyle>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function SidebarHeader({
|
|
61
|
+
leading,
|
|
62
|
+
actions,
|
|
63
|
+
extra,
|
|
64
|
+
divider = false,
|
|
65
|
+
style,
|
|
66
|
+
}: SidebarHeaderProps) {
|
|
67
|
+
const theme = useOctoSpacesTheme();
|
|
68
|
+
const { colors } = theme;
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<View
|
|
72
|
+
style={[
|
|
73
|
+
styles.root,
|
|
74
|
+
style,
|
|
75
|
+
divider
|
|
76
|
+
? {
|
|
77
|
+
borderBottomWidth: StyleSheet.hairlineWidth,
|
|
78
|
+
borderBottomColor: colors.borderSubtle,
|
|
79
|
+
}
|
|
80
|
+
: undefined,
|
|
81
|
+
]}
|
|
82
|
+
>
|
|
83
|
+
{/* Row 1: leading + actions */}
|
|
84
|
+
<View style={styles.row}>
|
|
85
|
+
<View style={styles.leading}>{leading}</View>
|
|
86
|
+
{actions != null ? <View style={styles.actions}>{actions}</View> : null}
|
|
87
|
+
</View>
|
|
88
|
+
{/* Extra slot (ModeSwitcher, jump-to bar, etc.) */}
|
|
89
|
+
{extra != null ? <View>{extra}</View> : null}
|
|
90
|
+
</View>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const styles = StyleSheet.create({
|
|
95
|
+
root: {
|
|
96
|
+
flexDirection: 'column',
|
|
97
|
+
},
|
|
98
|
+
row: {
|
|
99
|
+
flexDirection: 'row',
|
|
100
|
+
alignItems: 'center',
|
|
101
|
+
},
|
|
102
|
+
leading: {
|
|
103
|
+
flex: 1,
|
|
104
|
+
minWidth: 0,
|
|
105
|
+
},
|
|
106
|
+
actions: {
|
|
107
|
+
flexDirection: 'row',
|
|
108
|
+
alignItems: 'center',
|
|
109
|
+
flexShrink: 0,
|
|
110
|
+
},
|
|
111
|
+
});
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic themed sidebar navigation row.
|
|
3
|
+
*
|
|
4
|
+
* The headless analog of `DiscoverRow` for the sidebar panel. Suitable for
|
|
5
|
+
* simple link-style rows (explore, threads, pinned, …). Complex item lists
|
|
6
|
+
* (OctoVault's `ObjectTree`, OctoChat's `RoomCategoryList`) use their own row
|
|
7
|
+
* components — this primitive is for straightforward nav items.
|
|
8
|
+
*
|
|
9
|
+
* Active rows are highlighted with `colors.sidebarActive`. Hovered rows receive
|
|
10
|
+
* a subtle `colors.primarySubtle` wash.
|
|
11
|
+
*
|
|
12
|
+
* ```tsx
|
|
13
|
+
* <SidebarItem
|
|
14
|
+
* label="Threads"
|
|
15
|
+
* icon={<Icon name="thread" size={15} color={colors.textSecondary} />}
|
|
16
|
+
* active={threadsActive}
|
|
17
|
+
* onPress={onOpenThreads}
|
|
18
|
+
* />
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
import React, { useState } from 'react';
|
|
22
|
+
import { Pressable as RNPressable, StyleSheet, Text, View } from 'react-native';
|
|
23
|
+
import type { PressableProps, TextStyle, View as RNView } from 'react-native';
|
|
24
|
+
|
|
25
|
+
import { useOctoSpacesTheme } from '../theme/provider.js';
|
|
26
|
+
|
|
27
|
+
type HoverProps = { onMouseEnter?: () => void; onMouseLeave?: () => void };
|
|
28
|
+
const Pressable = RNPressable as React.ForwardRefExoticComponent<
|
|
29
|
+
PressableProps & HoverProps & React.RefAttributes<RNView>
|
|
30
|
+
>;
|
|
31
|
+
|
|
32
|
+
export interface SidebarItemProps {
|
|
33
|
+
label: string;
|
|
34
|
+
/** Leading icon element — the host provides the icon component. */
|
|
35
|
+
icon?: React.ReactNode;
|
|
36
|
+
/** Highlight the row as the current destination. */
|
|
37
|
+
active?: boolean;
|
|
38
|
+
/** Badge shown at the trailing edge — a number or short string. */
|
|
39
|
+
badge?: number | string;
|
|
40
|
+
onPress: () => void;
|
|
41
|
+
onLongPress?: () => void;
|
|
42
|
+
/** Additional trailing element (e.g. an action button). */
|
|
43
|
+
trailing?: React.ReactNode;
|
|
44
|
+
/**
|
|
45
|
+
* Left indentation level for nested items. Each level adds 16 px.
|
|
46
|
+
* @default 0
|
|
47
|
+
*/
|
|
48
|
+
indent?: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function SidebarItem({
|
|
52
|
+
label,
|
|
53
|
+
icon,
|
|
54
|
+
active = false,
|
|
55
|
+
badge,
|
|
56
|
+
onPress,
|
|
57
|
+
onLongPress,
|
|
58
|
+
trailing,
|
|
59
|
+
indent = 0,
|
|
60
|
+
}: SidebarItemProps) {
|
|
61
|
+
const theme = useOctoSpacesTheme();
|
|
62
|
+
const { colors, type: typeScale, fonts, spacing, radii } = theme;
|
|
63
|
+
|
|
64
|
+
const [hovered, setHovered] = useState(false);
|
|
65
|
+
|
|
66
|
+
const sp1 = (spacing['1'] as number | undefined) ?? 4;
|
|
67
|
+
const sp2 = (spacing['2'] as number | undefined) ?? 8;
|
|
68
|
+
const sp3 = (spacing['3'] as number | undefined) ?? 12;
|
|
69
|
+
const radSm = (radii['sm'] as number | undefined) ?? 4;
|
|
70
|
+
const indentPx = indent * 16;
|
|
71
|
+
|
|
72
|
+
const bg = active
|
|
73
|
+
? colors.sidebarActive
|
|
74
|
+
: hovered
|
|
75
|
+
? (colors.primarySubtle ?? 'rgba(0,0,0,0.04)')
|
|
76
|
+
: 'transparent';
|
|
77
|
+
|
|
78
|
+
const textColor = active ? colors.primary : colors.text;
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<Pressable
|
|
82
|
+
onPress={onPress}
|
|
83
|
+
onLongPress={onLongPress}
|
|
84
|
+
onMouseEnter={() => setHovered(true)}
|
|
85
|
+
onMouseLeave={() => setHovered(false)}
|
|
86
|
+
accessibilityRole="button"
|
|
87
|
+
accessibilityLabel={label}
|
|
88
|
+
style={{
|
|
89
|
+
flexDirection: 'row',
|
|
90
|
+
alignItems: 'center',
|
|
91
|
+
gap: sp2,
|
|
92
|
+
paddingVertical: sp1 + 2,
|
|
93
|
+
paddingLeft: sp3 + indentPx,
|
|
94
|
+
paddingRight: sp3,
|
|
95
|
+
borderRadius: radSm,
|
|
96
|
+
backgroundColor: bg,
|
|
97
|
+
}}
|
|
98
|
+
>
|
|
99
|
+
{icon != null ? <View style={styles.iconSlot}>{icon}</View> : null}
|
|
100
|
+
<Text
|
|
101
|
+
style={
|
|
102
|
+
{
|
|
103
|
+
flex: 1,
|
|
104
|
+
fontSize: typeScale['callout']?.size ?? 13,
|
|
105
|
+
lineHeight: typeScale['callout']?.lineHeight ?? 18,
|
|
106
|
+
fontWeight: active ? '600' : '400',
|
|
107
|
+
color: textColor,
|
|
108
|
+
fontFamily: fonts['body'] ?? undefined,
|
|
109
|
+
} as TextStyle
|
|
110
|
+
}
|
|
111
|
+
numberOfLines={1}
|
|
112
|
+
>
|
|
113
|
+
{label}
|
|
114
|
+
</Text>
|
|
115
|
+
{badge != null ? (
|
|
116
|
+
<View
|
|
117
|
+
style={{
|
|
118
|
+
minWidth: 16,
|
|
119
|
+
height: 16,
|
|
120
|
+
borderRadius: 8,
|
|
121
|
+
backgroundColor: active ? colors.primary : colors.textTertiary,
|
|
122
|
+
alignItems: 'center',
|
|
123
|
+
justifyContent: 'center',
|
|
124
|
+
paddingHorizontal: sp1,
|
|
125
|
+
}}
|
|
126
|
+
>
|
|
127
|
+
<Text
|
|
128
|
+
style={
|
|
129
|
+
{
|
|
130
|
+
fontSize: typeScale['micro']?.size ?? 10,
|
|
131
|
+
lineHeight: typeScale['micro']?.lineHeight ?? 14,
|
|
132
|
+
fontWeight: '700',
|
|
133
|
+
color: active ? colors.textOnPrimary : colors.textInverse,
|
|
134
|
+
} as TextStyle
|
|
135
|
+
}
|
|
136
|
+
>
|
|
137
|
+
{String(badge)}
|
|
138
|
+
</Text>
|
|
139
|
+
</View>
|
|
140
|
+
) : null}
|
|
141
|
+
{trailing != null ? trailing : null}
|
|
142
|
+
</Pressable>
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const styles = StyleSheet.create({
|
|
147
|
+
iconSlot: { width: 18, alignItems: 'center', justifyContent: 'center' },
|
|
148
|
+
});
|