@cosxai/ui 0.8.4 → 0.9.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosxai/ui",
3
- "version": "0.8.4",
3
+ "version": "0.9.0",
4
4
  "description": "COSX design system — React 19 component primitives shared across product-meta and other consumers",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -0,0 +1,171 @@
1
+ import { forwardRef, type CSSProperties } from "react";
2
+
3
+ // ActionBarModeHandle — the top-edge peek button that lets a user
4
+ // switch the ActionBar between two content sets ("modes"). Sits
5
+ // above the ActionBar's centred bottom position, showing only a
6
+ // slim 6px sliver at rest; grows on hover / focus to a full 28px
7
+ // tappable disc with a tooltip beneath the pointer.
8
+ //
9
+ // Compose with: `useActionBarMode` hook (or your own state) to swap
10
+ // the items registered via useActionBarItems when the handle fires
11
+ // onClick. Gate visibility on a permission bit (e.g.
12
+ // `document.capabilities.manage`) — passing `visible={false}` hides
13
+ // the handle entirely so non-privileged users never see the
14
+ // affordance.
15
+ //
16
+ // Visual: fixed positioning centred at the bottom of the viewport,
17
+ // stacked ABOVE the ActionBar (which lives at `bottom: 24px +
18
+ // tabbar-height`). The handle sits ~6px above the ActionBar's top
19
+ // edge at rest and animates upward on hover.
20
+ //
21
+ // Positioning contract: consumers may pass `bottom` to override the
22
+ // default 66px (24 gutter + 48 bar height - 6 overlap). Otherwise
23
+ // this matches the default ActionBar's centred bottom placement.
24
+ //
25
+ // Forwards ref to the underlying <button>.
26
+ export interface ActionBarModeHandleProps {
27
+ /**
28
+ * Called when the handle is clicked / activated. Consumer should
29
+ * flip the active mode state here (which drives
30
+ * useActionBarItems(sourceKey, items) with a different sourceKey).
31
+ */
32
+ onClick: () => void;
33
+ /**
34
+ * Human-readable label describing what the click will do
35
+ * ("Switch to manage mode"). Used as both aria-label and the
36
+ * hover tooltip.
37
+ */
38
+ label: string;
39
+ /**
40
+ * When false, the handle renders nothing. Consumers wire this to
41
+ * their capability check (e.g. `capabilities.manage === true`)
42
+ * so principals without the permission never see the affordance.
43
+ * Default: true.
44
+ */
45
+ visible?: boolean | undefined;
46
+ /**
47
+ * Optional bottom offset in px (default: matches the ActionBar's
48
+ * default centred position — 66px = 24 gutter + 48 bar height - 6
49
+ * overlap, plus the same tabbar + safe-area terms). Override when
50
+ * the ActionBar is docked non-default.
51
+ */
52
+ bottom?: number | string | undefined;
53
+ /**
54
+ * Extra className appended to the handle's root class. Handy for
55
+ * tests + one-off styling. Default className: `ck-actionbar-handle`.
56
+ */
57
+ className?: string | undefined;
58
+ /**
59
+ * Extra inline styles merged onto the root. Only use for one-off
60
+ * z-index / offset overrides; the visual language should stay
61
+ * consistent across surfaces.
62
+ */
63
+ style?: CSSProperties | undefined;
64
+ }
65
+
66
+ const HANDLE_REST_WIDTH = 44;
67
+ const HANDLE_REST_HEIGHT = 6;
68
+ const HANDLE_HOVER_WIDTH = 68;
69
+ const HANDLE_HOVER_HEIGHT = 24;
70
+ // Overlap the ActionBar's top edge by 4px so the handle looks
71
+ // tethered to the bar rather than floating above it.
72
+ const HANDLE_BOTTOM_DEFAULT = "calc(24px + 48px - 4px + var(--ck-tabbar-height, 0px) + env(safe-area-inset-bottom, 0px))";
73
+
74
+ export const ActionBarModeHandle = forwardRef<HTMLButtonElement, ActionBarModeHandleProps>(
75
+ function ActionBarModeHandle(
76
+ {
77
+ onClick,
78
+ label,
79
+ visible = true,
80
+ bottom,
81
+ className,
82
+ style,
83
+ },
84
+ ref,
85
+ ) {
86
+ if (!visible) return null;
87
+ const resolvedBottom =
88
+ typeof bottom === "number"
89
+ ? `${bottom}px`
90
+ : typeof bottom === "string"
91
+ ? bottom
92
+ : HANDLE_BOTTOM_DEFAULT;
93
+ return (
94
+ <button
95
+ ref={ref}
96
+ type="button"
97
+ onClick={onClick}
98
+ aria-label={label}
99
+ title={label}
100
+ className={`ck-actionbar-handle${className ? ` ${className}` : ""}`}
101
+ style={{
102
+ position: "fixed",
103
+ left: "50%",
104
+ bottom: resolvedBottom,
105
+ transform: "translateX(-50%)",
106
+ // Rest state: narrow slim strip at the top edge of the bar.
107
+ width: `var(--ck-actionbar-handle-w, ${HANDLE_REST_WIDTH}px)`,
108
+ height: `var(--ck-actionbar-handle-h, ${HANDLE_REST_HEIGHT}px)`,
109
+ padding: 0,
110
+ margin: 0,
111
+ border: 0,
112
+ borderRadius: 999,
113
+ background: "var(--ck-accent, #4f46e5)",
114
+ color: "var(--ck-accent-fg, #fff)",
115
+ fontSize: 11,
116
+ fontWeight: 500,
117
+ letterSpacing: 0.2,
118
+ lineHeight: 1,
119
+ overflow: "hidden",
120
+ whiteSpace: "nowrap",
121
+ cursor: "pointer",
122
+ // Sit above the ActionBar (z-index 80 in ActionBar.tsx).
123
+ zIndex: 85,
124
+ boxShadow: "0 2px 8px rgba(0,0,0,0.14)",
125
+ transition:
126
+ "width 220ms cubic-bezier(0.34, 1.56, 0.64, 1)," +
127
+ "height 220ms cubic-bezier(0.34, 1.56, 0.64, 1)," +
128
+ "box-shadow 220ms ease-out",
129
+ display: "inline-flex",
130
+ alignItems: "center",
131
+ justifyContent: "center",
132
+ ...style,
133
+ }}
134
+ // Inline hover/focus expansion — pure CSS on the same element
135
+ // via CSSProperties gives us the animation without a global
136
+ // stylesheet dependency in the kit.
137
+ onMouseEnter={(e) => {
138
+ const el = e.currentTarget;
139
+ el.style.setProperty("--ck-actionbar-handle-w", `${HANDLE_HOVER_WIDTH}px`);
140
+ el.style.setProperty("--ck-actionbar-handle-h", `${HANDLE_HOVER_HEIGHT}px`);
141
+ el.style.boxShadow = "0 4px 14px rgba(0,0,0,0.22)";
142
+ }}
143
+ onMouseLeave={(e) => {
144
+ const el = e.currentTarget;
145
+ el.style.setProperty("--ck-actionbar-handle-w", `${HANDLE_REST_WIDTH}px`);
146
+ el.style.setProperty("--ck-actionbar-handle-h", `${HANDLE_REST_HEIGHT}px`);
147
+ el.style.boxShadow = "0 2px 8px rgba(0,0,0,0.14)";
148
+ }}
149
+ onFocus={(e) => {
150
+ const el = e.currentTarget;
151
+ el.style.setProperty("--ck-actionbar-handle-w", `${HANDLE_HOVER_WIDTH}px`);
152
+ el.style.setProperty("--ck-actionbar-handle-h", `${HANDLE_HOVER_HEIGHT}px`);
153
+ el.style.boxShadow = "0 4px 14px rgba(0,0,0,0.22)";
154
+ }}
155
+ onBlur={(e) => {
156
+ const el = e.currentTarget;
157
+ el.style.setProperty("--ck-actionbar-handle-w", `${HANDLE_REST_WIDTH}px`);
158
+ el.style.setProperty("--ck-actionbar-handle-h", `${HANDLE_REST_HEIGHT}px`);
159
+ el.style.boxShadow = "0 2px 8px rgba(0,0,0,0.14)";
160
+ }}
161
+ >
162
+ {/* Only visible when expanded — CSS `overflow: hidden` on the
163
+ button clips it at rest. Kept as visible text (not aria-
164
+ hidden) so screen readers can still hear it via title. */}
165
+ <span aria-hidden style={{ opacity: 0.95 }}>
166
+ {label}
167
+ </span>
168
+ </button>
169
+ );
170
+ },
171
+ );
@@ -6,6 +6,14 @@ export { ActionBarButton } from "./ActionBarButton";
6
6
  export type { ActionBarButtonProps } from "./ActionBarButton";
7
7
  export { useActionBarItems, useActionBarItemsContext } from "./useActionBarItems";
8
8
  export { useActionBarStatusDot } from "./useActionBarStatusDot";
9
+ export { ActionBarModeHandle } from "./ActionBarModeHandle";
10
+ export type { ActionBarModeHandleProps } from "./ActionBarModeHandle";
11
+ export { useActionBarMode } from "./useActionBarMode";
12
+ export type {
13
+ ActionBarModeConfig,
14
+ UseActionBarModeOptions,
15
+ UseActionBarModeResult,
16
+ } from "./useActionBarMode";
9
17
  export type {
10
18
  ActionBarItem,
11
19
  ActionBarItemVariant,
@@ -0,0 +1,144 @@
1
+ import { useCallback, useEffect, useMemo, useState } from "react";
2
+ import { useActionBarItems } from "./useActionBarItems";
3
+ import type { ActionBarItem } from "./types";
4
+
5
+ // useActionBarMode — one hook that manages a small set of named
6
+ // "modes" (each with its own item list) + registers the active mode's
7
+ // items into the ActionBar registry. Consumer combines this with
8
+ // <ActionBarModeHandle> to give privileged users a peek-out affordance
9
+ // that flips between the modes.
10
+ //
11
+ // Design intent (M4.5 block_doc surface):
12
+ // - Default mode: standard viewer actions (comment / download / share)
13
+ // - Manage mode: admin-only shortcuts (preview draft / manage share /
14
+ // view activity), gated behind `capabilities.manage === true`
15
+ // - Draft mode (optional third): appears once the user enters draft
16
+ // preview; distinct action set (publish / discard / raw JSON /
17
+ // return)
18
+ //
19
+ // The mode name is the ActionBar's source key, so switching modes
20
+ // unregisters the previous set and registers the new set atomically
21
+ // (useActionBarItems handles the cleanup).
22
+ //
23
+ // State persistence: pass `storageKey` for localStorage-backed
24
+ // stickiness across reloads. Nil-safe when window is unavailable
25
+ // (SSR).
26
+
27
+ export type ActionBarModeConfig<K extends string> = {
28
+ [key in K]: ActionBarItem[];
29
+ };
30
+
31
+ export interface UseActionBarModeOptions<K extends string> {
32
+ /**
33
+ * Map of mode keys → items. Each mode key becomes the source key
34
+ * passed to useActionBarItems, so it must be a stable string
35
+ * (e.g. "viewer", "manage") — mode registrations will not
36
+ * "cross-contaminate" the item set of another mode.
37
+ */
38
+ modes: ActionBarModeConfig<K>;
39
+ /**
40
+ * Which mode to start in. Overridden by localStorage when
41
+ * `storageKey` is set AND localStorage has a valid value.
42
+ */
43
+ defaultMode: K;
44
+ /**
45
+ * Optional localStorage key. When set, the current mode is
46
+ * persisted + rehydrated across reloads.
47
+ */
48
+ storageKey?: string | undefined;
49
+ }
50
+
51
+ export interface UseActionBarModeResult<K extends string> {
52
+ /** The currently active mode key. */
53
+ mode: K;
54
+ /** Set the mode explicitly (typically wired to the mode Handle). */
55
+ setMode: (next: K) => void;
56
+ /**
57
+ * Toggle between the FIRST two modes in the config's iteration
58
+ * order. Convenience for the common two-mode case; a
59
+ * three-mode consumer uses setMode directly.
60
+ */
61
+ toggle: () => void;
62
+ /**
63
+ * All configured mode keys in registration order — useful for
64
+ * building a cycle-through-modes button.
65
+ */
66
+ allModes: readonly K[];
67
+ }
68
+
69
+ function loadPersistedMode<K extends string>(
70
+ storageKey: string,
71
+ allModes: readonly K[],
72
+ ): K | null {
73
+ if (typeof window === "undefined") return null;
74
+ try {
75
+ const raw = window.localStorage.getItem(storageKey);
76
+ if (raw && (allModes as readonly string[]).includes(raw)) {
77
+ return raw as K;
78
+ }
79
+ } catch {
80
+ /* ignore */
81
+ }
82
+ return null;
83
+ }
84
+
85
+ function persistMode(storageKey: string, mode: string) {
86
+ if (typeof window === "undefined") return;
87
+ try {
88
+ window.localStorage.setItem(storageKey, mode);
89
+ } catch {
90
+ /* ignore */
91
+ }
92
+ }
93
+
94
+ export function useActionBarMode<K extends string>(
95
+ opts: UseActionBarModeOptions<K>,
96
+ ): UseActionBarModeResult<K> {
97
+ const { modes, defaultMode, storageKey } = opts;
98
+ const allModes = useMemo(() => Object.keys(modes) as K[], [modes]);
99
+ const [mode, setModeState] = useState<K>(() => {
100
+ if (storageKey) {
101
+ const persisted = loadPersistedMode<K>(storageKey, allModes);
102
+ if (persisted) return persisted;
103
+ }
104
+ return defaultMode;
105
+ });
106
+
107
+ // If the modes config changes (dev / hot reload) so the current
108
+ // mode key is no longer valid, drop back to the default.
109
+ useEffect(() => {
110
+ if (!(allModes as readonly string[]).includes(mode)) {
111
+ setModeState(defaultMode);
112
+ }
113
+ }, [mode, allModes, defaultMode]);
114
+
115
+ const setMode = useCallback(
116
+ (next: K) => {
117
+ setModeState(next);
118
+ if (storageKey) persistMode(storageKey, next);
119
+ },
120
+ [storageKey],
121
+ );
122
+
123
+ const toggle = useCallback(() => {
124
+ if (allModes.length < 2) return;
125
+ const idx = allModes.indexOf(mode);
126
+ // Cycle to the NEXT mode; wrap-around handles both the "toggle
127
+ // between 2" and "cycle through 3+" cases.
128
+ const nextIdx = (idx + 1) % allModes.length;
129
+ const next = allModes[nextIdx];
130
+ if (next !== undefined) {
131
+ setMode(next);
132
+ }
133
+ }, [allModes, mode, setMode]);
134
+
135
+ // Register the active mode's items with the ActionBar. Uses the
136
+ // mode key as the source key, so switching modes atomically
137
+ // unregisters the previous set. Items themselves must be stable
138
+ // (useMemo'd) — the ActionBar deduplicates by reference, so passing
139
+ // `modes[mode]` re-uses the caller's memoized array.
140
+ const activeItems = modes[mode];
141
+ useActionBarItems(mode, activeItems);
142
+
143
+ return { mode, setMode, toggle, allModes };
144
+ }