@cosxai/ui 1.0.0-alpha.11 → 1.0.0-alpha.12
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/CHANGELOG.md +14 -0
- package/package.json +1 -1
- package/src/components/Avatar.tsx +5 -5
- package/src/components/BottomSheet.tsx +138 -36
- package/src/components.css +10 -0
- package/src/styles.css +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
## 1.0.0-alpha.12 (2026-10-04)
|
|
2
|
+
|
|
3
|
+
From the Metaroom app (Motion & Native Feel §04, Account):
|
|
4
|
+
|
|
5
|
+
- BottomSheet: rises in 280ms and now leaves in 220ms instead of vanishing
|
|
6
|
+
(it stays mounted for its exit; the scrim fades with it and carries
|
|
7
|
+
`data-leaving` while it goes). `detents="two"`: half (60%) and full —
|
|
8
|
+
typing in it, scrolling its list or dragging up lifts it to full,
|
|
9
|
+
dragging down from full goes back to half; dragging above its top gives
|
|
10
|
+
way at 0.3×. `action` (the title row's button) and `footer` (pinned below
|
|
11
|
+
the list). The rise starts when Radix puts the content on the page, so it
|
|
12
|
+
always plays. Buttons and fields in the title row don't start a drag.
|
|
13
|
+
- Avatar: size 64 (Account's profile photo), initials at 20px.
|
|
14
|
+
|
|
1
15
|
## 1.0.0-alpha.11 (2026-10-01)
|
|
2
16
|
|
|
3
17
|
App shell for the Agent module (Metaroom Agent, desktop and phone):
|
package/package.json
CHANGED
|
@@ -3,11 +3,11 @@ import { forwardRef, useEffect, useRef, useState, type ComponentProps } from 're
|
|
|
3
3
|
import { cn } from '../lib/cn';
|
|
4
4
|
import { initialsOf } from './ActivityTimeline';
|
|
5
5
|
|
|
6
|
-
export type AvatarSize = 20 | 24 | 28 | 32 | 36 | 40 | 52;
|
|
6
|
+
export type AvatarSize = 20 | 24 | 28 | 32 | 36 | 40 | 52 | 64;
|
|
7
7
|
|
|
8
8
|
// Initials size per avatar size (Metaroom Agent / Account: 22→9, 28→10,
|
|
9
|
-
// 32→11, 40→13, 52→16).
|
|
10
|
-
const TYPE: Record<AvatarSize, number> = { 20: 9, 24: 9, 28: 10, 32: 11, 36: 12, 40: 13, 52: 16 };
|
|
9
|
+
// 32→11, 40→13, 52→16, Account's profile photo 64→20).
|
|
10
|
+
const TYPE: Record<AvatarSize, number> = { 20: 9, 24: 9, 28: 10, 32: 11, 36: 12, 40: 13, 52: 16, 64: 20 };
|
|
11
11
|
|
|
12
12
|
export type AvatarProps = Omit<ComponentProps<'span'>, 'children'> & {
|
|
13
13
|
/** The person's name: gives the initials (two Latin initials, or the first hanzi). */
|
|
@@ -29,8 +29,8 @@ export type AvatarProps = Omit<ComponentProps<'span'>, 'children'> & {
|
|
|
29
29
|
|
|
30
30
|
/**
|
|
31
31
|
* Avatar — a person: their photo, or their initials on a round disc. Sizes
|
|
32
|
-
* 20–
|
|
33
|
-
* 28–40, profile
|
|
32
|
+
* 20–64 as the Metaroom app uses them (rail account 32, phone tab 24, lists
|
|
33
|
+
* 28–40, Me card 52, Account's profile photo 64). For the Agent use MetaAvatar (@cosxai/chat); for a
|
|
34
34
|
* workspace, WorkspaceMark.
|
|
35
35
|
*
|
|
36
36
|
* Compose with: AppRail `account` slot (inside a button), BottomTabs item
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
|
2
|
-
import { useCallback, useEffect, useRef, useState, type ComponentProps, type PointerEvent as ReactPointerEvent, type ReactElement, type ReactNode } from 'react';
|
|
2
|
+
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type ComponentProps, type PointerEvent as ReactPointerEvent, type ReactElement, type ReactNode } from 'react';
|
|
3
3
|
|
|
4
4
|
import { cn } from '../lib/cn';
|
|
5
5
|
import { useReducedMotion } from './status-motion';
|
|
@@ -11,6 +11,17 @@ export type BottomSheetProps = Omit<ComponentProps<typeof DialogPrimitive.Conten
|
|
|
11
11
|
hideTitle?: boolean | undefined;
|
|
12
12
|
/** A line under the title, read out with it. */
|
|
13
13
|
description?: ReactNode;
|
|
14
|
+
/** The main button, at the end of the title row (seen at half height too). */
|
|
15
|
+
action?: ReactNode;
|
|
16
|
+
/** Pinned below the scrolling content, above the home indicator (a Done or Add button). */
|
|
17
|
+
footer?: ReactNode;
|
|
18
|
+
/**
|
|
19
|
+
* "content": as tall as what it holds, up to half the screen — menus and
|
|
20
|
+
* confirmations. "two": half (60%) and full — browsing and picking;
|
|
21
|
+
* typing in it, scrolling its list or dragging it up lifts it to full,
|
|
22
|
+
* dragging down from full goes back to half. @default "content"
|
|
23
|
+
*/
|
|
24
|
+
detents?: 'content' | 'two' | undefined;
|
|
14
25
|
children?: ReactNode;
|
|
15
26
|
open?: boolean | undefined;
|
|
16
27
|
defaultOpen?: boolean | undefined;
|
|
@@ -21,18 +32,37 @@ export type BottomSheetProps = Omit<ComponentProps<typeof DialogPrimitive.Conten
|
|
|
21
32
|
closeLabel?: string | undefined;
|
|
22
33
|
};
|
|
23
34
|
|
|
24
|
-
//
|
|
35
|
+
// Motion & Native Feel §04: rises in 280ms, leaves in 220ms (a quarter faster).
|
|
36
|
+
const ENTER_MS = 280;
|
|
37
|
+
const EXIT_MS = 220;
|
|
38
|
+
const ENTER = 'cubic-bezier(.16,1,.3,1)';
|
|
39
|
+
const EXIT = 'cubic-bezier(.4,0,1,1)';
|
|
40
|
+
const RELEASE = 'cubic-bezier(.2,.8,.2,1)';
|
|
41
|
+
// A drag past this share of the sheet's height (at most CLOSE_MAX), or a flick, closes it.
|
|
25
42
|
const CLOSE_SHARE = 0.25;
|
|
26
43
|
const CLOSE_MAX = 120;
|
|
27
44
|
const FLICK = 0.6; // px per ms
|
|
45
|
+
// Dragged above its top, the sheet gives way at this rate.
|
|
46
|
+
const OVERSHOOT = 0.3;
|
|
47
|
+
const HALF = 0.6;
|
|
48
|
+
|
|
49
|
+
const currentY = (el: HTMLElement) => {
|
|
50
|
+
try {
|
|
51
|
+
return new DOMMatrixReadOnly(getComputedStyle(el).transform).m42 || 0;
|
|
52
|
+
} catch {
|
|
53
|
+
return 0;
|
|
54
|
+
}
|
|
55
|
+
};
|
|
28
56
|
|
|
29
57
|
/**
|
|
30
|
-
* BottomSheet — the phone's
|
|
31
|
-
*
|
|
32
|
-
* workspace, language
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
58
|
+
* BottomSheet — the phone's sheet: a page-coloured sheet rising from the
|
|
59
|
+
* bottom with a drag handle and a title (Metaroom Agent phone: switch
|
|
60
|
+
* workspace, language, an object's menu; link files, with two heights).
|
|
61
|
+
* 20px top corners over the scrim; the bottom padding clears the home
|
|
62
|
+
* indicator (safe area). Rises in 280ms and leaves in 220ms. Drag the
|
|
63
|
+
* handle or the title row down, or flick, to close (from full height: back
|
|
64
|
+
* to half); Esc, a tap on the scrim and the screen-reader close button
|
|
65
|
+
* close it too. Built on Radix Dialog: focus moves in, is trapped and
|
|
36
66
|
* returns to the opener.
|
|
37
67
|
*
|
|
38
68
|
* Compose with: BottomTabs (the Me tab opens it), rows of 48–56px buttons
|
|
@@ -42,6 +72,9 @@ export function BottomSheet({
|
|
|
42
72
|
title,
|
|
43
73
|
hideTitle = false,
|
|
44
74
|
description,
|
|
75
|
+
action,
|
|
76
|
+
footer,
|
|
77
|
+
detents = 'content',
|
|
45
78
|
children,
|
|
46
79
|
open: openProp,
|
|
47
80
|
defaultOpen = false,
|
|
@@ -51,6 +84,7 @@ export function BottomSheet({
|
|
|
51
84
|
className,
|
|
52
85
|
style,
|
|
53
86
|
onOpenAutoFocus,
|
|
87
|
+
onFocusCapture,
|
|
54
88
|
...rest
|
|
55
89
|
}: BottomSheetProps) {
|
|
56
90
|
const [openState, setOpenState] = useState(defaultOpen);
|
|
@@ -64,64 +98,121 @@ export function BottomSheet({
|
|
|
64
98
|
);
|
|
65
99
|
|
|
66
100
|
const reduce = useReducedMotion();
|
|
101
|
+
// Mounted while open and while leaving (the exit plays before it goes)
|
|
102
|
+
const [mounted, setMounted] = useState(open);
|
|
103
|
+
const [full, setFull] = useState(false);
|
|
67
104
|
const sheet = useRef<HTMLDivElement | null>(null);
|
|
105
|
+
const scrim = useRef<HTMLDivElement | null>(null);
|
|
68
106
|
const drag = useRef<{ y0: number; t0: number } | null>(null);
|
|
69
|
-
const
|
|
70
|
-
const [settling, setSettling] = useState(false);
|
|
107
|
+
const entering = useRef(open);
|
|
71
108
|
|
|
72
|
-
|
|
73
|
-
if (
|
|
109
|
+
useLayoutEffect(() => {
|
|
110
|
+
if (open) {
|
|
111
|
+
entering.current = true;
|
|
112
|
+
setMounted(true);
|
|
113
|
+
setFull(false);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
if (!mounted) return;
|
|
117
|
+
const el = sheet.current;
|
|
118
|
+
if (!el || reduce || typeof el.animate !== 'function') {
|
|
119
|
+
setMounted(false);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
// The scrim's leaving is a CSS animation: listeners (a status bar
|
|
123
|
+
// following the page) hear it start, as with Dialog's
|
|
124
|
+
scrim.current?.setAttribute('data-leaving', '');
|
|
125
|
+
const from = currentY(el) || parseFloat(el.style.transform.replace(/[^\d.-]/g, '')) || 0;
|
|
126
|
+
el.style.transform = '';
|
|
127
|
+
el.animate([{ transform: `translateY(${from}px)` }, { transform: 'translateY(100%)' }], { duration: EXIT_MS, easing: EXIT, fill: 'forwards' }).onfinish = () => setMounted(false);
|
|
128
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
74
129
|
}, [open]);
|
|
75
130
|
|
|
131
|
+
// Radix puts the content on the page a render after ours: the rise starts when it arrives
|
|
132
|
+
const setSheet = (el: HTMLDivElement | null) => {
|
|
133
|
+
sheet.current = el;
|
|
134
|
+
if (!el || !entering.current) return;
|
|
135
|
+
entering.current = false;
|
|
136
|
+
if (reduce || typeof el.animate !== 'function') return;
|
|
137
|
+
el.animate([{ transform: 'translateY(100%)' }, { transform: 'translateY(0)' }], { duration: ENTER_MS, easing: ENTER });
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
useEffect(() => () => void (drag.current = null), []);
|
|
141
|
+
|
|
142
|
+
const height = () => sheet.current?.offsetHeight ?? 0;
|
|
143
|
+
const settle = () => {
|
|
144
|
+
const el = sheet.current;
|
|
145
|
+
if (!el) return;
|
|
146
|
+
const from = el.style.transform;
|
|
147
|
+
el.style.transform = '';
|
|
148
|
+
if (from && !reduce && typeof el.animate === 'function') {
|
|
149
|
+
el.animate([{ transform: from }, { transform: 'translateY(0)' }], { duration: 200, easing: RELEASE });
|
|
150
|
+
}
|
|
151
|
+
};
|
|
76
152
|
const onDown = (e: ReactPointerEvent<HTMLDivElement>) => {
|
|
77
153
|
if (e.button !== 0) return;
|
|
154
|
+
if ((e.target as HTMLElement).closest('button, a, input, textarea, select')) return;
|
|
78
155
|
drag.current = { y0: e.clientY, t0: performance.now() };
|
|
79
|
-
setSettling(false);
|
|
80
156
|
e.currentTarget.setPointerCapture?.(e.pointerId);
|
|
81
157
|
};
|
|
82
158
|
const onMove = (e: ReactPointerEvent<HTMLDivElement>) => {
|
|
83
|
-
|
|
159
|
+
const d = drag.current;
|
|
160
|
+
if (!d || !sheet.current) return;
|
|
161
|
+
const raw = e.clientY - d.y0;
|
|
162
|
+
const dy = raw < 0 ? raw * OVERSHOOT : raw;
|
|
163
|
+
sheet.current.style.transform = `translateY(${dy}px)`;
|
|
84
164
|
};
|
|
85
165
|
const onUp = (e: ReactPointerEvent<HTMLDivElement>) => {
|
|
86
166
|
const d = drag.current;
|
|
87
167
|
drag.current = null;
|
|
88
168
|
if (!d) return;
|
|
89
|
-
const dist =
|
|
169
|
+
const dist = e.clientY - d.y0;
|
|
90
170
|
const speed = dist / Math.max(1, performance.now() - d.t0);
|
|
91
|
-
const
|
|
92
|
-
const limit =
|
|
93
|
-
|
|
94
|
-
if (
|
|
95
|
-
|
|
171
|
+
const h = height();
|
|
172
|
+
const limit = h > 0 ? Math.min(CLOSE_MAX, h * CLOSE_SHARE) : CLOSE_MAX;
|
|
173
|
+
const down = dist > limit || (dist > 8 && speed > FLICK);
|
|
174
|
+
if (down && detents === 'two' && full) {
|
|
175
|
+
setFull(false);
|
|
176
|
+
settle();
|
|
177
|
+
} else if (down) {
|
|
178
|
+
setOpen(false);
|
|
179
|
+
} else {
|
|
180
|
+
if (detents === 'two' && !full && (dist < -24 || speed < -FLICK)) setFull(true);
|
|
181
|
+
settle();
|
|
182
|
+
}
|
|
96
183
|
};
|
|
97
184
|
|
|
185
|
+
const two = detents === 'two';
|
|
98
186
|
return (
|
|
99
|
-
|
|
187
|
+
// Open while shown, leaving included; the trigger stays the same element throughout
|
|
188
|
+
<DialogPrimitive.Root open={mounted} onOpenChange={(o) => (o ? setOpen(true) : open && setOpen(false))}>
|
|
100
189
|
{trigger && <DialogPrimitive.Trigger asChild>{trigger}</DialogPrimitive.Trigger>}
|
|
190
|
+
{mounted && (
|
|
101
191
|
<DialogPrimitive.Portal>
|
|
102
|
-
<DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-scrim" />
|
|
192
|
+
<DialogPrimitive.Overlay ref={scrim} className="cosx-sheet-scrim fixed inset-0 z-50 bg-scrim" />
|
|
103
193
|
<DialogPrimitive.Content
|
|
104
|
-
ref={
|
|
194
|
+
ref={setSheet}
|
|
105
195
|
{...(description ? {} : { 'aria-describedby': undefined })}
|
|
106
196
|
onOpenAutoFocus={(e) => {
|
|
107
197
|
onOpenAutoFocus?.(e);
|
|
108
198
|
if (e.defaultPrevented) return;
|
|
109
199
|
// The sheet itself takes focus (read out by its title), not the first row.
|
|
110
200
|
e.preventDefault();
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
201
|
+
(e.currentTarget as HTMLElement | null)?.focus();
|
|
202
|
+
}}
|
|
203
|
+
// Typing lifts a two-height sheet to full (the keyboard needs the room)
|
|
204
|
+
onFocusCapture={(e) => {
|
|
205
|
+
onFocusCapture?.(e);
|
|
206
|
+
if (two && (e.target as HTMLElement).matches('input, textarea')) setFull(true);
|
|
116
207
|
}}
|
|
117
208
|
className={cn(
|
|
118
|
-
'fixed inset-x-0 bottom-0 z-50 flex
|
|
119
|
-
|
|
209
|
+
'fixed inset-x-0 bottom-0 z-50 flex flex-col rounded-t-[20px] bg-page px-4 pt-2 font-sans text-fg outline-none',
|
|
210
|
+
two ? 'transition-[height] duration-300 ease-[cubic-bezier(.2,0,0,1)]' : 'max-h-[90dvh]',
|
|
120
211
|
className,
|
|
121
212
|
)}
|
|
122
213
|
style={{
|
|
123
|
-
paddingBottom: 'max(30px, calc(env(safe-area-inset-bottom, 0px) + 12px))',
|
|
124
|
-
...(
|
|
214
|
+
paddingBottom: footer ? 'max(16px, calc(env(safe-area-inset-bottom, 0px) + 8px))' : 'max(30px, calc(env(safe-area-inset-bottom, 0px) + 12px))',
|
|
215
|
+
...(two ? { height: full ? 'calc(100dvh - env(safe-area-inset-top, 0px) - 8px)' : `${HALF * 100}dvh` } : {}),
|
|
125
216
|
...style,
|
|
126
217
|
}}
|
|
127
218
|
{...rest}
|
|
@@ -134,18 +225,29 @@ export function BottomSheet({
|
|
|
134
225
|
onPointerUp={onUp}
|
|
135
226
|
onPointerCancel={() => {
|
|
136
227
|
drag.current = null;
|
|
137
|
-
|
|
138
|
-
setDy(0);
|
|
228
|
+
settle();
|
|
139
229
|
}}
|
|
140
230
|
>
|
|
141
231
|
<span aria-hidden className="mb-2.5 h-1 w-9 self-center rounded-pill bg-rule" />
|
|
142
|
-
<
|
|
232
|
+
<div className="flex items-center gap-3">
|
|
233
|
+
<DialogPrimitive.Title className={cn('m-0 min-w-0 flex-1 truncate px-1 pb-2 text-[17px] leading-[1.3] font-medium', hideTitle && 'sr-only')}>{title}</DialogPrimitive.Title>
|
|
234
|
+
{action && <div className="shrink-0 pb-2">{action}</div>}
|
|
235
|
+
</div>
|
|
143
236
|
{description && <DialogPrimitive.Description className="m-0 px-1 pb-2 text-small text-fg-secondary">{description}</DialogPrimitive.Description>}
|
|
144
237
|
</div>
|
|
145
|
-
<div
|
|
238
|
+
<div
|
|
239
|
+
className={cn('flex min-h-0 flex-1 flex-col gap-0.5', two && !full ? 'overflow-hidden' : 'overflow-y-auto overscroll-contain')}
|
|
240
|
+
// At half height, a pull up on the list lifts the sheet first
|
|
241
|
+
onWheel={(e) => two && !full && e.deltaY > 0 && setFull(true)}
|
|
242
|
+
onTouchMove={() => two && !full && setFull(true)}
|
|
243
|
+
>
|
|
244
|
+
{children}
|
|
245
|
+
</div>
|
|
246
|
+
{footer && <div className="shrink-0 border-t border-rule-soft pt-2.5">{footer}</div>}
|
|
146
247
|
<DialogPrimitive.Close className="sr-only">{closeLabel}</DialogPrimitive.Close>
|
|
147
248
|
</DialogPrimitive.Content>
|
|
148
249
|
</DialogPrimitive.Portal>
|
|
250
|
+
)}
|
|
149
251
|
</DialogPrimitive.Root>
|
|
150
252
|
);
|
|
151
253
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/* Component motion that has to be CSS (listeners hear animationstart):
|
|
2
|
+
BottomSheet's scrim fades in with the sheet (280ms) and out as it
|
|
3
|
+
leaves (220ms, data-leaving) — Motion & Native Feel §04. */
|
|
4
|
+
@keyframes cosx-sheet-scrim-in { from { opacity: 0; } }
|
|
5
|
+
@keyframes cosx-sheet-scrim-out { to { opacity: 0; } }
|
|
6
|
+
.cosx-sheet-scrim { animation: cosx-sheet-scrim-in 280ms cubic-bezier(.16,1,.3,1); }
|
|
7
|
+
.cosx-sheet-scrim[data-leaving] { animation: cosx-sheet-scrim-out 220ms cubic-bezier(.4,0,1,1) forwards; }
|
|
8
|
+
@media (prefers-reduced-motion: reduce) {
|
|
9
|
+
.cosx-sheet-scrim, .cosx-sheet-scrim[data-leaving] { animation: none; }
|
|
10
|
+
}
|
package/src/styles.css
CHANGED