@cosxai/ui 0.8.3 → 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 +1 -1
- package/src/actionbar/ActionBarModeHandle.tsx +171 -0
- package/src/actionbar/index.ts +8 -0
- package/src/actionbar/useActionBarMode.ts +144 -0
- package/src/styles/fonts.css +1026 -28
package/package.json
CHANGED
|
@@ -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
|
+
);
|
package/src/actionbar/index.ts
CHANGED
|
@@ -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
|
+
}
|