@cosxai/ui 1.0.0-alpha.10 → 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 +34 -0
- package/package.json +1 -1
- package/src/components/AppRail.tsx +141 -0
- package/src/components/Avatar.tsx +75 -0
- package/src/components/BottomSheet.tsx +253 -0
- package/src/components/BottomTabs.tsx +102 -0
- package/src/components/Popover.tsx +46 -0
- package/src/components.css +10 -0
- package/src/index.ts +7 -0
- package/src/styles.css +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,37 @@
|
|
|
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
|
+
|
|
15
|
+
## 1.0.0-alpha.11 (2026-10-01)
|
|
16
|
+
|
|
17
|
+
App shell for the Agent module (Metaroom Agent, desktop and phone):
|
|
18
|
+
|
|
19
|
+
- Popover, PopoverTrigger, PopoverContent, PopoverAnchor, PopoverClose: a
|
|
20
|
+
small non-modal panel on Radix Popover — portal, flips to fit, Esc and
|
|
21
|
+
outside click close, focus returns. `inline` keeps it out of the portal.
|
|
22
|
+
- Avatar: a person's photo or initials, sizes 20–52, tone chrome / brand,
|
|
23
|
+
optional ring; decorative unless `label` is given.
|
|
24
|
+
- BottomSheet: the phone's sheet on Radix Dialog — drag handle, title, drag
|
|
25
|
+
or flick down to close, scrim, 20px top corners, safe-area padding, a
|
|
26
|
+
screen-reader close button.
|
|
27
|
+
- BottomTabs: the phone tab bar — 48×28 pill in the brand field for the
|
|
28
|
+
current tab, counts, avatar tab, disabled tabs at 40% (aria-disabled,
|
|
29
|
+
`onDisabledSelect`).
|
|
30
|
+
- AppRail + AppRailSlot: the 60px desktop rail — workspace slot, 40×38
|
|
31
|
+
module buttons (brand field when current), counts and dots, disabled
|
|
32
|
+
modules with the caller's tooltip, account slot. A nav of buttons with
|
|
33
|
+
aria-current.
|
|
34
|
+
|
|
1
35
|
## 1.0.0-alpha.10 (2026-09-30)
|
|
2
36
|
|
|
3
37
|
- BrandLoader: the COSX loop as a loading indicator (Claude Design, COSX
|
package/package.json
CHANGED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import type { LucideIcon } from 'lucide-react';
|
|
2
|
+
import { forwardRef, type ComponentProps, type ReactNode } from 'react';
|
|
3
|
+
|
|
4
|
+
import { cn } from '../lib/cn';
|
|
5
|
+
import { formatCount } from './Button';
|
|
6
|
+
import { Icon } from './Icon';
|
|
7
|
+
import { Tooltip } from './Tooltip';
|
|
8
|
+
|
|
9
|
+
export type AppRailItem = {
|
|
10
|
+
key: string;
|
|
11
|
+
icon: LucideIcon;
|
|
12
|
+
/** The module's name: accessible name and tooltip. */
|
|
13
|
+
label: string;
|
|
14
|
+
/** A count on the corner; hidden at zero (and on a disabled item), 99+ above 99. */
|
|
15
|
+
count?: number | undefined;
|
|
16
|
+
/** A dot on the corner — something new, no number. A count wins. */
|
|
17
|
+
dot?: boolean | undefined;
|
|
18
|
+
/** Not available yet: 40% opacity, aria-disabled (still focusable); a press calls `onDisabledSelect`. */
|
|
19
|
+
disabled?: boolean | undefined;
|
|
20
|
+
/** The tooltip while disabled ("My tasks · Coming soon"); the caller words it. @default label */
|
|
21
|
+
disabledHint?: string | undefined;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type AppRailProps = Omit<ComponentProps<'nav'>, 'onChange' | 'children'> & {
|
|
25
|
+
items: AppRailItem[];
|
|
26
|
+
/** The current module's key. */
|
|
27
|
+
value: string;
|
|
28
|
+
onChange: (key: string) => void;
|
|
29
|
+
/** A disabled item was pressed — say why (a toast: "Coming soon"). */
|
|
30
|
+
onDisabledSelect?: ((key: string) => void) | undefined;
|
|
31
|
+
/** The landmark's accessible name ("Modules"). */
|
|
32
|
+
label: string;
|
|
33
|
+
/** Top slot: the workspace, usually an AppRailSlot holding a 36px WorkspaceMark. */
|
|
34
|
+
workspace?: ReactNode;
|
|
35
|
+
/** Bottom slot: the account, usually an AppRailSlot shape="round" holding a 32px Avatar. */
|
|
36
|
+
account?: ReactNode;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* AppRail — the 60px desktop module rail (Metaroom Agent): the workspace at
|
|
41
|
+
* the top, one 40×38 icon button per module, a flexible gap, the account at
|
|
42
|
+
* the bottom. The current module takes the workspace's field colour
|
|
43
|
+
* (--brand-field); modules not built yet stay visible at 40% with a tooltip
|
|
44
|
+
* that says so. Counts and dots use --brand-mark.
|
|
45
|
+
*
|
|
46
|
+
* Compose with: AppRailSlot + WorkspaceMark / Avatar for the slots, each
|
|
47
|
+
* usually a PopoverTrigger (workspace switcher, account card); BottomTabs is
|
|
48
|
+
* the same navigation on phones.
|
|
49
|
+
*
|
|
50
|
+
* A nav landmark of buttons; the current one has aria-current="page".
|
|
51
|
+
* Forwards ref to the <nav>; spreads `...rest` onto it.
|
|
52
|
+
*/
|
|
53
|
+
export const AppRail = forwardRef<HTMLElement, AppRailProps>(function AppRail(
|
|
54
|
+
{ items, value, onChange, onDisabledSelect, label, workspace, account, className, ...rest },
|
|
55
|
+
ref,
|
|
56
|
+
) {
|
|
57
|
+
return (
|
|
58
|
+
<nav
|
|
59
|
+
ref={ref}
|
|
60
|
+
aria-label={label}
|
|
61
|
+
className={cn('flex w-[60px] shrink-0 flex-col items-center gap-1 border-r border-rule bg-sunk py-3.5 font-sans text-fg', className)}
|
|
62
|
+
{...rest}
|
|
63
|
+
>
|
|
64
|
+
{workspace && <div className="mb-3 flex">{workspace}</div>}
|
|
65
|
+
{items.map((it) => {
|
|
66
|
+
const on = it.key === value;
|
|
67
|
+
const count = it.disabled || it.count === undefined ? null : formatCount(it.count);
|
|
68
|
+
return (
|
|
69
|
+
<Tooltip key={it.key} side="right" content={it.disabled ? (it.disabledHint ?? it.label) : it.label}>
|
|
70
|
+
<button
|
|
71
|
+
type="button"
|
|
72
|
+
aria-label={count ? `${it.label} (${count})` : it.label}
|
|
73
|
+
aria-current={on ? 'page' : undefined}
|
|
74
|
+
aria-disabled={it.disabled || undefined}
|
|
75
|
+
onClick={() => (it.disabled ? onDisabledSelect?.(it.key) : onChange(it.key))}
|
|
76
|
+
className={cn(
|
|
77
|
+
'relative grid h-[38px] w-10 shrink-0 cursor-pointer place-items-center rounded-md border-0 p-0 outline-none',
|
|
78
|
+
'transition-colors duration-[120ms] ease-standard focus-visible:shadow-(--focus-ring)',
|
|
79
|
+
on ? 'bg-brand-field text-ink' : 'bg-transparent text-fg',
|
|
80
|
+
!on && !it.disabled && 'hover:bg-hover',
|
|
81
|
+
it.disabled && 'cursor-not-allowed opacity-40',
|
|
82
|
+
)}
|
|
83
|
+
>
|
|
84
|
+
<Icon icon={it.icon} size={17} />
|
|
85
|
+
{count ? (
|
|
86
|
+
<span
|
|
87
|
+
aria-hidden
|
|
88
|
+
className="absolute top-1 right-[5px] h-3.5 min-w-3.5 rounded-pill bg-brand-mark px-[3px] text-center text-[9px] leading-[14px] font-bold text-ink tabular-nums"
|
|
89
|
+
>
|
|
90
|
+
{count}
|
|
91
|
+
</span>
|
|
92
|
+
) : (
|
|
93
|
+
it.dot && !it.disabled && <span aria-hidden data-dot="" className="absolute top-[7px] right-[9px] size-[7px] rounded-pill bg-brand-mark" />
|
|
94
|
+
)}
|
|
95
|
+
</button>
|
|
96
|
+
</Tooltip>
|
|
97
|
+
);
|
|
98
|
+
})}
|
|
99
|
+
<div className="flex-1" />
|
|
100
|
+
{account && <div className="flex">{account}</div>}
|
|
101
|
+
</nav>
|
|
102
|
+
);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
export type AppRailSlotProps = Omit<ComponentProps<'button'>, 'aria-label'> & {
|
|
106
|
+
/** Accessible name ("Switch workspace", "Account"). */
|
|
107
|
+
label: string;
|
|
108
|
+
/** tile (the workspace, 9px corners) · round (the account). @default "tile" */
|
|
109
|
+
shape?: 'tile' | 'round' | undefined;
|
|
110
|
+
/** Its popover is open: an ink ring with a gap. Set from the popover's open state. */
|
|
111
|
+
active?: boolean | undefined;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* AppRailSlot — the bare button around the rail's workspace mark or account
|
|
116
|
+
* avatar: no fill of its own, the focus ring, and the ink ring while its
|
|
117
|
+
* popover is open. Works as `PopoverTrigger asChild` (forwards ref and
|
|
118
|
+
* props).
|
|
119
|
+
*/
|
|
120
|
+
export const AppRailSlot = forwardRef<HTMLButtonElement, AppRailSlotProps>(function AppRailSlot(
|
|
121
|
+
{ label, shape = 'tile', active = false, className, children, ...rest },
|
|
122
|
+
ref,
|
|
123
|
+
) {
|
|
124
|
+
return (
|
|
125
|
+
<button
|
|
126
|
+
ref={ref}
|
|
127
|
+
type="button"
|
|
128
|
+
aria-label={label}
|
|
129
|
+
title={label}
|
|
130
|
+
className={cn(
|
|
131
|
+
'inline-flex shrink-0 cursor-pointer border-0 bg-transparent p-0 outline-none focus-visible:shadow-(--focus-ring) disabled:cursor-default',
|
|
132
|
+
shape === 'round' ? 'rounded-pill' : 'rounded-[9px]',
|
|
133
|
+
active && 'shadow-[0_0_0_2px_var(--bg-sunk),0_0_0_4px_var(--text-primary)]',
|
|
134
|
+
className,
|
|
135
|
+
)}
|
|
136
|
+
{...rest}
|
|
137
|
+
>
|
|
138
|
+
{children}
|
|
139
|
+
</button>
|
|
140
|
+
);
|
|
141
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { forwardRef, useEffect, useRef, useState, type ComponentProps } from 'react';
|
|
2
|
+
|
|
3
|
+
import { cn } from '../lib/cn';
|
|
4
|
+
import { initialsOf } from './ActivityTimeline';
|
|
5
|
+
|
|
6
|
+
export type AvatarSize = 20 | 24 | 28 | 32 | 36 | 40 | 52 | 64;
|
|
7
|
+
|
|
8
|
+
// Initials size per avatar size (Metaroom Agent / Account: 22→9, 28→10,
|
|
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
|
+
|
|
12
|
+
export type AvatarProps = Omit<ComponentProps<'span'>, 'children'> & {
|
|
13
|
+
/** The person's name: gives the initials (two Latin initials, or the first hanzi). */
|
|
14
|
+
name: string;
|
|
15
|
+
/** Their photo; the initials show while it is unset or when it fails to load. */
|
|
16
|
+
src?: string | undefined;
|
|
17
|
+
/** Initials to use instead of ones taken from `name`. */
|
|
18
|
+
initials?: string | undefined;
|
|
19
|
+
/** px. @default 32 */
|
|
20
|
+
size?: AvatarSize | undefined;
|
|
21
|
+
/** chrome (a grey disc) · brand (the workspace's field colour, for "you"). @default "chrome" */
|
|
22
|
+
tone?: 'chrome' | 'brand' | undefined;
|
|
23
|
+
/** An ink ring with a gap — the avatar's menu is open, or it is the current tab.
|
|
24
|
+
* The gap takes `--avatar-ring-gap` (default the page colour); set it to the ground behind. */
|
|
25
|
+
ring?: boolean | undefined;
|
|
26
|
+
/** Accessible name. Without it the avatar is decorative: the name sits beside it. */
|
|
27
|
+
label?: string | undefined;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Avatar — a person: their photo, or their initials on a round disc. Sizes
|
|
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
|
+
* workspace, WorkspaceMark.
|
|
35
|
+
*
|
|
36
|
+
* Compose with: AppRail `account` slot (inside a button), BottomTabs item
|
|
37
|
+
* `avatar`, list rows beside the person's name (decorative there).
|
|
38
|
+
*
|
|
39
|
+
* Forwards ref to the root <span>; spreads `...rest` onto it.
|
|
40
|
+
*/
|
|
41
|
+
export const Avatar = forwardRef<HTMLSpanElement, AvatarProps>(function Avatar(
|
|
42
|
+
{ name, src, initials, size = 32, tone = 'chrome', ring = false, label, className, style, ...rest },
|
|
43
|
+
ref,
|
|
44
|
+
) {
|
|
45
|
+
const [failed, setFailed] = useState(false);
|
|
46
|
+
const img = useRef<HTMLImageElement>(null);
|
|
47
|
+
useEffect(() => setFailed(false), [src]);
|
|
48
|
+
// A server-rendered page can fail the image before React is listening.
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
const el = img.current;
|
|
51
|
+
if (el?.complete && el.naturalWidth === 0) setFailed(true);
|
|
52
|
+
}, [src]);
|
|
53
|
+
const photo = Boolean(src) && !failed;
|
|
54
|
+
return (
|
|
55
|
+
<span
|
|
56
|
+
ref={ref}
|
|
57
|
+
{...(label ? { role: 'img', 'aria-label': label } : { 'aria-hidden': true })}
|
|
58
|
+
data-tone={tone}
|
|
59
|
+
className={cn(
|
|
60
|
+
'inline-grid shrink-0 place-items-center overflow-hidden rounded-pill font-sans leading-none font-semibold select-none',
|
|
61
|
+
tone === 'brand' ? 'bg-brand-field text-ink' : 'bg-chrome text-fg',
|
|
62
|
+
ring && 'shadow-[0_0_0_2px_var(--avatar-ring-gap,var(--bg-page)),0_0_0_4px_var(--text-primary)]',
|
|
63
|
+
className,
|
|
64
|
+
)}
|
|
65
|
+
style={{ width: size, height: size, fontSize: TYPE[size] ?? Math.round(size * 0.32), ...style }}
|
|
66
|
+
{...rest}
|
|
67
|
+
>
|
|
68
|
+
{photo ? (
|
|
69
|
+
<img ref={img} src={src} alt="" className="size-full object-cover" onError={() => setFailed(true)} />
|
|
70
|
+
) : (
|
|
71
|
+
(initials ?? initialsOf(name))
|
|
72
|
+
)}
|
|
73
|
+
</span>
|
|
74
|
+
);
|
|
75
|
+
});
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
|
2
|
+
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type ComponentProps, type PointerEvent as ReactPointerEvent, type ReactElement, type ReactNode } from 'react';
|
|
3
|
+
|
|
4
|
+
import { cn } from '../lib/cn';
|
|
5
|
+
import { useReducedMotion } from './status-motion';
|
|
6
|
+
|
|
7
|
+
export type BottomSheetProps = Omit<ComponentProps<typeof DialogPrimitive.Content>, 'title' | 'children' | 'ref'> & {
|
|
8
|
+
/** Says what the sheet is for ("Switch workspace", "Language"). Labels the dialog. */
|
|
9
|
+
title: ReactNode;
|
|
10
|
+
/** Keep the title for screen readers only. @default false */
|
|
11
|
+
hideTitle?: boolean | undefined;
|
|
12
|
+
/** A line under the title, read out with it. */
|
|
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;
|
|
25
|
+
children?: ReactNode;
|
|
26
|
+
open?: boolean | undefined;
|
|
27
|
+
defaultOpen?: boolean | undefined;
|
|
28
|
+
onOpenChange?: ((open: boolean) => void) | undefined;
|
|
29
|
+
/** The element that opens it (rendered asChild). Or control `open`. */
|
|
30
|
+
trigger?: ReactElement | undefined;
|
|
31
|
+
/** Accessible name of the screen-reader close button (touch readers have no Esc). @default "Close" */
|
|
32
|
+
closeLabel?: string | undefined;
|
|
33
|
+
};
|
|
34
|
+
|
|
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.
|
|
42
|
+
const CLOSE_SHARE = 0.25;
|
|
43
|
+
const CLOSE_MAX = 120;
|
|
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
|
+
};
|
|
56
|
+
|
|
57
|
+
/**
|
|
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
|
|
66
|
+
* returns to the opener.
|
|
67
|
+
*
|
|
68
|
+
* Compose with: BottomTabs (the Me tab opens it), rows of 48–56px buttons
|
|
69
|
+
* inside. On desktop use Popover or DialogContent instead.
|
|
70
|
+
*/
|
|
71
|
+
export function BottomSheet({
|
|
72
|
+
title,
|
|
73
|
+
hideTitle = false,
|
|
74
|
+
description,
|
|
75
|
+
action,
|
|
76
|
+
footer,
|
|
77
|
+
detents = 'content',
|
|
78
|
+
children,
|
|
79
|
+
open: openProp,
|
|
80
|
+
defaultOpen = false,
|
|
81
|
+
onOpenChange,
|
|
82
|
+
trigger,
|
|
83
|
+
closeLabel = 'Close',
|
|
84
|
+
className,
|
|
85
|
+
style,
|
|
86
|
+
onOpenAutoFocus,
|
|
87
|
+
onFocusCapture,
|
|
88
|
+
...rest
|
|
89
|
+
}: BottomSheetProps) {
|
|
90
|
+
const [openState, setOpenState] = useState(defaultOpen);
|
|
91
|
+
const open = openProp ?? openState;
|
|
92
|
+
const setOpen = useCallback(
|
|
93
|
+
(next: boolean) => {
|
|
94
|
+
if (openProp === undefined) setOpenState(next);
|
|
95
|
+
onOpenChange?.(next);
|
|
96
|
+
},
|
|
97
|
+
[openProp, onOpenChange],
|
|
98
|
+
);
|
|
99
|
+
|
|
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);
|
|
104
|
+
const sheet = useRef<HTMLDivElement | null>(null);
|
|
105
|
+
const scrim = useRef<HTMLDivElement | null>(null);
|
|
106
|
+
const drag = useRef<{ y0: number; t0: number } | null>(null);
|
|
107
|
+
const entering = useRef(open);
|
|
108
|
+
|
|
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
|
|
129
|
+
}, [open]);
|
|
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
|
+
};
|
|
152
|
+
const onDown = (e: ReactPointerEvent<HTMLDivElement>) => {
|
|
153
|
+
if (e.button !== 0) return;
|
|
154
|
+
if ((e.target as HTMLElement).closest('button, a, input, textarea, select')) return;
|
|
155
|
+
drag.current = { y0: e.clientY, t0: performance.now() };
|
|
156
|
+
e.currentTarget.setPointerCapture?.(e.pointerId);
|
|
157
|
+
};
|
|
158
|
+
const onMove = (e: ReactPointerEvent<HTMLDivElement>) => {
|
|
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)`;
|
|
164
|
+
};
|
|
165
|
+
const onUp = (e: ReactPointerEvent<HTMLDivElement>) => {
|
|
166
|
+
const d = drag.current;
|
|
167
|
+
drag.current = null;
|
|
168
|
+
if (!d) return;
|
|
169
|
+
const dist = e.clientY - d.y0;
|
|
170
|
+
const speed = dist / Math.max(1, performance.now() - d.t0);
|
|
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
|
+
}
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const two = detents === 'two';
|
|
186
|
+
return (
|
|
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))}>
|
|
189
|
+
{trigger && <DialogPrimitive.Trigger asChild>{trigger}</DialogPrimitive.Trigger>}
|
|
190
|
+
{mounted && (
|
|
191
|
+
<DialogPrimitive.Portal>
|
|
192
|
+
<DialogPrimitive.Overlay ref={scrim} className="cosx-sheet-scrim fixed inset-0 z-50 bg-scrim" />
|
|
193
|
+
<DialogPrimitive.Content
|
|
194
|
+
ref={setSheet}
|
|
195
|
+
{...(description ? {} : { 'aria-describedby': undefined })}
|
|
196
|
+
onOpenAutoFocus={(e) => {
|
|
197
|
+
onOpenAutoFocus?.(e);
|
|
198
|
+
if (e.defaultPrevented) return;
|
|
199
|
+
// The sheet itself takes focus (read out by its title), not the first row.
|
|
200
|
+
e.preventDefault();
|
|
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);
|
|
207
|
+
}}
|
|
208
|
+
className={cn(
|
|
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]',
|
|
211
|
+
className,
|
|
212
|
+
)}
|
|
213
|
+
style={{
|
|
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` } : {}),
|
|
216
|
+
...style,
|
|
217
|
+
}}
|
|
218
|
+
{...rest}
|
|
219
|
+
>
|
|
220
|
+
<div
|
|
221
|
+
data-sheet-drag=""
|
|
222
|
+
className="flex shrink-0 cursor-grab touch-none flex-col select-none active:cursor-grabbing"
|
|
223
|
+
onPointerDown={onDown}
|
|
224
|
+
onPointerMove={onMove}
|
|
225
|
+
onPointerUp={onUp}
|
|
226
|
+
onPointerCancel={() => {
|
|
227
|
+
drag.current = null;
|
|
228
|
+
settle();
|
|
229
|
+
}}
|
|
230
|
+
>
|
|
231
|
+
<span aria-hidden className="mb-2.5 h-1 w-9 self-center rounded-pill bg-rule" />
|
|
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>
|
|
236
|
+
{description && <DialogPrimitive.Description className="m-0 px-1 pb-2 text-small text-fg-secondary">{description}</DialogPrimitive.Description>}
|
|
237
|
+
</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>}
|
|
247
|
+
<DialogPrimitive.Close className="sr-only">{closeLabel}</DialogPrimitive.Close>
|
|
248
|
+
</DialogPrimitive.Content>
|
|
249
|
+
</DialogPrimitive.Portal>
|
|
250
|
+
)}
|
|
251
|
+
</DialogPrimitive.Root>
|
|
252
|
+
);
|
|
253
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { LucideIcon } from 'lucide-react';
|
|
2
|
+
import { forwardRef, type ComponentProps, type ReactNode } from 'react';
|
|
3
|
+
|
|
4
|
+
import { cn } from '../lib/cn';
|
|
5
|
+
import { formatCount } from './Button';
|
|
6
|
+
import { Icon } from './Icon';
|
|
7
|
+
|
|
8
|
+
export type BottomTabItem = {
|
|
9
|
+
key: string;
|
|
10
|
+
/** A Lucide icon. Ignored when `avatar` is set. */
|
|
11
|
+
icon?: LucideIcon | undefined;
|
|
12
|
+
label: string;
|
|
13
|
+
/** A count on the corner; hidden at zero (and on a disabled tab), 99+ above 99. */
|
|
14
|
+
count?: number | undefined;
|
|
15
|
+
/** Not available yet: 40% opacity, aria-disabled; a press calls `onDisabledSelect`. */
|
|
16
|
+
disabled?: boolean | undefined;
|
|
17
|
+
/** Shown in place of the icon — the person's Avatar (size 24) on the Me tab. */
|
|
18
|
+
avatar?: ReactNode;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type BottomTabsProps = Omit<ComponentProps<'nav'>, 'onChange' | 'children'> & {
|
|
22
|
+
items: BottomTabItem[];
|
|
23
|
+
/** The current tab's key. */
|
|
24
|
+
value: string;
|
|
25
|
+
onChange: (key: string) => void;
|
|
26
|
+
/** A disabled tab was pressed — say why (a toast: "Coming soon"). */
|
|
27
|
+
onDisabledSelect?: ((key: string) => void) | undefined;
|
|
28
|
+
/** The landmark's accessible name ("Modules"). */
|
|
29
|
+
label: string;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* BottomTabs — the phone's tab bar (Metaroom Agent phone): an icon in a
|
|
34
|
+
* 48×28 pill over an 11px label; the current tab's pill takes the
|
|
35
|
+
* workspace's field colour (--brand-field), a person's avatar gets an ink
|
|
36
|
+
* ring. Tabs not built yet stay visible at 40% opacity (aria-disabled, still
|
|
37
|
+
* focusable, so a press can explain). The bottom padding clears the home
|
|
38
|
+
* indicator.
|
|
39
|
+
*
|
|
40
|
+
* Compose with: Avatar (size 24) for the Me tab, BottomSheet opened from
|
|
41
|
+
* it; AppRail is the same navigation on desktop.
|
|
42
|
+
*
|
|
43
|
+
* A nav landmark of buttons; the current one has aria-current="page".
|
|
44
|
+
* Forwards ref to the <nav>; spreads `...rest` onto it.
|
|
45
|
+
*/
|
|
46
|
+
export const BottomTabs = forwardRef<HTMLElement, BottomTabsProps>(function BottomTabs(
|
|
47
|
+
{ items, value, onChange, onDisabledSelect, label, className, style, ...rest },
|
|
48
|
+
ref,
|
|
49
|
+
) {
|
|
50
|
+
return (
|
|
51
|
+
<nav
|
|
52
|
+
ref={ref}
|
|
53
|
+
aria-label={label}
|
|
54
|
+
className={cn('flex shrink-0 border-t border-rule bg-page px-2 pt-1.5 font-sans text-fg', className)}
|
|
55
|
+
style={{ paddingBottom: 'max(22px, env(safe-area-inset-bottom, 0px))', ...style }}
|
|
56
|
+
{...rest}
|
|
57
|
+
>
|
|
58
|
+
{items.map((it) => {
|
|
59
|
+
const on = it.key === value;
|
|
60
|
+
const count = it.disabled || it.count === undefined ? null : formatCount(it.count);
|
|
61
|
+
return (
|
|
62
|
+
<button
|
|
63
|
+
key={it.key}
|
|
64
|
+
type="button"
|
|
65
|
+
aria-current={on ? 'page' : undefined}
|
|
66
|
+
aria-disabled={it.disabled || undefined}
|
|
67
|
+
aria-label={count ? `${it.label} (${count})` : undefined}
|
|
68
|
+
onClick={() => (it.disabled ? onDisabledSelect?.(it.key) : onChange(it.key))}
|
|
69
|
+
className={cn(
|
|
70
|
+
'relative flex min-h-12 flex-1 cursor-pointer flex-col items-center justify-center gap-[3px] rounded-md border-0 bg-transparent p-0 text-fg outline-none focus-visible:shadow-(--focus-ring)',
|
|
71
|
+
it.disabled && 'cursor-not-allowed opacity-40',
|
|
72
|
+
)}
|
|
73
|
+
>
|
|
74
|
+
<span
|
|
75
|
+
className={cn(
|
|
76
|
+
'grid h-7 w-12 place-items-center rounded-pill transition-colors duration-[120ms] ease-standard',
|
|
77
|
+
on && 'bg-brand-field text-ink',
|
|
78
|
+
)}
|
|
79
|
+
>
|
|
80
|
+
{it.avatar ? (
|
|
81
|
+
<span aria-hidden className={cn('inline-flex rounded-pill', on && 'shadow-[0_0_0_1.5px_var(--text-primary)]')}>
|
|
82
|
+
{it.avatar}
|
|
83
|
+
</span>
|
|
84
|
+
) : (
|
|
85
|
+
it.icon && <Icon icon={it.icon} size={18} />
|
|
86
|
+
)}
|
|
87
|
+
</span>
|
|
88
|
+
<span className="text-[11px] leading-none font-medium">{it.label}</span>
|
|
89
|
+
{count && (
|
|
90
|
+
<span
|
|
91
|
+
aria-hidden
|
|
92
|
+
className="absolute top-0.5 left-1/2 ml-2 h-3.5 min-w-3.5 rounded-pill bg-brand-mark px-[3px] text-center text-[9px] leading-[14px] font-bold text-ink tabular-nums"
|
|
93
|
+
>
|
|
94
|
+
{count}
|
|
95
|
+
</span>
|
|
96
|
+
)}
|
|
97
|
+
</button>
|
|
98
|
+
);
|
|
99
|
+
})}
|
|
100
|
+
</nav>
|
|
101
|
+
);
|
|
102
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import * as PopoverPrimitive from '@radix-ui/react-popover';
|
|
2
|
+
import type { ComponentProps } from 'react';
|
|
3
|
+
|
|
4
|
+
import { cn } from '../lib/cn';
|
|
5
|
+
|
|
6
|
+
/** Popover root: `open` / `onOpenChange`, or uncontrolled with PopoverTrigger. */
|
|
7
|
+
export const Popover = PopoverPrimitive.Root;
|
|
8
|
+
/** The element that opens it (asChild onto a Button or IconButton). Focus returns here on close. */
|
|
9
|
+
export const PopoverTrigger = PopoverPrimitive.Trigger;
|
|
10
|
+
/** Positions the content against something other than the trigger (a row, a caret). */
|
|
11
|
+
export const PopoverAnchor = PopoverPrimitive.Anchor;
|
|
12
|
+
/** Closes the popover (asChild onto a Done button). */
|
|
13
|
+
export const PopoverClose = PopoverPrimitive.Close;
|
|
14
|
+
|
|
15
|
+
export type PopoverContentProps = ComponentProps<typeof PopoverPrimitive.Content> & {
|
|
16
|
+
/** Keep it in the tree without a portal (inside a dialog that must contain it). @default false */
|
|
17
|
+
inline?: boolean | undefined;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* PopoverContent — a small non-modal panel next to its trigger: the files of
|
|
22
|
+
* a conversation, a workspace switcher, the account card (Metaroom Agent).
|
|
23
|
+
* Page colour, 14px corners, a hairline edge; no shadow (3.0 has none).
|
|
24
|
+
* Renders in a portal and flips to stay in view (placement auto, Radix
|
|
25
|
+
* Popover); Esc and an outside click close it and focus returns to the
|
|
26
|
+
* trigger.
|
|
27
|
+
*
|
|
28
|
+
* Compose with: PopoverTrigger asChild onto an IconButton / AppRail slot;
|
|
29
|
+
* Menu for a list of actions instead (it brings arrow-key navigation).
|
|
30
|
+
*/
|
|
31
|
+
export function PopoverContent({ className, sideOffset = 6, collisionPadding = 8, align = 'start', inline = false, ...rest }: PopoverContentProps) {
|
|
32
|
+
const content = (
|
|
33
|
+
<PopoverPrimitive.Content
|
|
34
|
+
sideOffset={sideOffset}
|
|
35
|
+
collisionPadding={collisionPadding}
|
|
36
|
+
align={align}
|
|
37
|
+
className={cn(
|
|
38
|
+
'z-[60] w-[320px] max-w-[calc(100vw-16px)] overflow-y-auto rounded-[14px] border border-rule bg-page p-2 font-sans text-ui text-fg outline-none',
|
|
39
|
+
'max-h-(--radix-popover-content-available-height)',
|
|
40
|
+
className,
|
|
41
|
+
)}
|
|
42
|
+
{...rest}
|
|
43
|
+
/>
|
|
44
|
+
);
|
|
45
|
+
return inline ? content : <PopoverPrimitive.Portal>{content}</PopoverPrimitive.Portal>;
|
|
46
|
+
}
|
|
@@ -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/index.ts
CHANGED
|
@@ -77,3 +77,10 @@ export { WindowDrop, useWindowDrop, type WindowDropProps } from './components/Wi
|
|
|
77
77
|
export { AuthIconTile, AuthLayout, AuthPanel, AuthStepHead, type AuthIconTileProps, type AuthLayoutProps, type AuthPanelProps, type AuthStepHeadProps } from './components/AuthLayout';
|
|
78
78
|
export { ThemeSwitch, type ThemeSwitchLabels, type ThemeSwitchProps } from './components/ThemeSwitch';
|
|
79
79
|
export { WorkspaceMark, WorkspaceRow, type WorkspaceMarkProps, type WorkspaceRowProps } from './components/WorkspaceRow';
|
|
80
|
+
|
|
81
|
+
// Stage 4 — app shell (Metaroom Agent)
|
|
82
|
+
export { AppRail, AppRailSlot, type AppRailItem, type AppRailProps, type AppRailSlotProps } from './components/AppRail';
|
|
83
|
+
export { Avatar, type AvatarProps, type AvatarSize } from './components/Avatar';
|
|
84
|
+
export { BottomSheet, type BottomSheetProps } from './components/BottomSheet';
|
|
85
|
+
export { BottomTabs, type BottomTabItem, type BottomTabsProps } from './components/BottomTabs';
|
|
86
|
+
export { Popover, PopoverAnchor, PopoverClose, PopoverContent, PopoverTrigger, type PopoverContentProps } from './components/Popover';
|
package/src/styles.css
CHANGED