@cosxai/ui 1.0.0-alpha.10 → 1.0.0-alpha.11
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 +20 -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 +151 -0
- package/src/components/BottomTabs.tsx +102 -0
- package/src/components/Popover.tsx +46 -0
- package/src/index.ts +7 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,23 @@
|
|
|
1
|
+
## 1.0.0-alpha.11 (2026-10-01)
|
|
2
|
+
|
|
3
|
+
App shell for the Agent module (Metaroom Agent, desktop and phone):
|
|
4
|
+
|
|
5
|
+
- Popover, PopoverTrigger, PopoverContent, PopoverAnchor, PopoverClose: a
|
|
6
|
+
small non-modal panel on Radix Popover — portal, flips to fit, Esc and
|
|
7
|
+
outside click close, focus returns. `inline` keeps it out of the portal.
|
|
8
|
+
- Avatar: a person's photo or initials, sizes 20–52, tone chrome / brand,
|
|
9
|
+
optional ring; decorative unless `label` is given.
|
|
10
|
+
- BottomSheet: the phone's sheet on Radix Dialog — drag handle, title, drag
|
|
11
|
+
or flick down to close, scrim, 20px top corners, safe-area padding, a
|
|
12
|
+
screen-reader close button.
|
|
13
|
+
- BottomTabs: the phone tab bar — 48×28 pill in the brand field for the
|
|
14
|
+
current tab, counts, avatar tab, disabled tabs at 40% (aria-disabled,
|
|
15
|
+
`onDisabledSelect`).
|
|
16
|
+
- AppRail + AppRailSlot: the 60px desktop rail — workspace slot, 40×38
|
|
17
|
+
module buttons (brand field when current), counts and dots, disabled
|
|
18
|
+
modules with the caller's tooltip, account slot. A nav of buttons with
|
|
19
|
+
aria-current.
|
|
20
|
+
|
|
1
21
|
## 1.0.0-alpha.10 (2026-09-30)
|
|
2
22
|
|
|
3
23
|
- 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;
|
|
7
|
+
|
|
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 };
|
|
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–52 as the Metaroom app uses them (rail account 32, phone tab 24, lists
|
|
33
|
+
* 28–40, profile 52). 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,151 @@
|
|
|
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';
|
|
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
|
+
children?: ReactNode;
|
|
15
|
+
open?: boolean | undefined;
|
|
16
|
+
defaultOpen?: boolean | undefined;
|
|
17
|
+
onOpenChange?: ((open: boolean) => void) | undefined;
|
|
18
|
+
/** The element that opens it (rendered asChild). Or control `open`. */
|
|
19
|
+
trigger?: ReactElement | undefined;
|
|
20
|
+
/** Accessible name of the screen-reader close button (touch readers have no Esc). @default "Close" */
|
|
21
|
+
closeLabel?: string | undefined;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
// A drag past this share of the sheet's height, or a flick, closes it.
|
|
25
|
+
const CLOSE_SHARE = 0.25;
|
|
26
|
+
const CLOSE_MAX = 120;
|
|
27
|
+
const FLICK = 0.6; // px per ms
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* BottomSheet — the phone's light choice: a page-coloured sheet rising from
|
|
31
|
+
* the bottom with a drag handle and a title (Metaroom Agent phone: switch
|
|
32
|
+
* workspace, language). 20px top corners over the scrim; the bottom padding
|
|
33
|
+
* clears the home indicator (safe area). Drag the handle or the title down,
|
|
34
|
+
* or flick, to close; Esc, a tap on the scrim and the screen-reader close
|
|
35
|
+
* button close it too. Built on Radix Dialog: focus moves in, is trapped and
|
|
36
|
+
* returns to the opener.
|
|
37
|
+
*
|
|
38
|
+
* Compose with: BottomTabs (the Me tab opens it), rows of 48–56px buttons
|
|
39
|
+
* inside. On desktop use Popover or DialogContent instead.
|
|
40
|
+
*/
|
|
41
|
+
export function BottomSheet({
|
|
42
|
+
title,
|
|
43
|
+
hideTitle = false,
|
|
44
|
+
description,
|
|
45
|
+
children,
|
|
46
|
+
open: openProp,
|
|
47
|
+
defaultOpen = false,
|
|
48
|
+
onOpenChange,
|
|
49
|
+
trigger,
|
|
50
|
+
closeLabel = 'Close',
|
|
51
|
+
className,
|
|
52
|
+
style,
|
|
53
|
+
onOpenAutoFocus,
|
|
54
|
+
...rest
|
|
55
|
+
}: BottomSheetProps) {
|
|
56
|
+
const [openState, setOpenState] = useState(defaultOpen);
|
|
57
|
+
const open = openProp ?? openState;
|
|
58
|
+
const setOpen = useCallback(
|
|
59
|
+
(next: boolean) => {
|
|
60
|
+
if (openProp === undefined) setOpenState(next);
|
|
61
|
+
onOpenChange?.(next);
|
|
62
|
+
},
|
|
63
|
+
[openProp, onOpenChange],
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
const reduce = useReducedMotion();
|
|
67
|
+
const sheet = useRef<HTMLDivElement | null>(null);
|
|
68
|
+
const drag = useRef<{ y0: number; t0: number } | null>(null);
|
|
69
|
+
const [dy, setDy] = useState(0);
|
|
70
|
+
const [settling, setSettling] = useState(false);
|
|
71
|
+
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
if (!open) setDy(0);
|
|
74
|
+
}, [open]);
|
|
75
|
+
|
|
76
|
+
const onDown = (e: ReactPointerEvent<HTMLDivElement>) => {
|
|
77
|
+
if (e.button !== 0) return;
|
|
78
|
+
drag.current = { y0: e.clientY, t0: performance.now() };
|
|
79
|
+
setSettling(false);
|
|
80
|
+
e.currentTarget.setPointerCapture?.(e.pointerId);
|
|
81
|
+
};
|
|
82
|
+
const onMove = (e: ReactPointerEvent<HTMLDivElement>) => {
|
|
83
|
+
if (drag.current) setDy(Math.max(0, e.clientY - drag.current.y0));
|
|
84
|
+
};
|
|
85
|
+
const onUp = (e: ReactPointerEvent<HTMLDivElement>) => {
|
|
86
|
+
const d = drag.current;
|
|
87
|
+
drag.current = null;
|
|
88
|
+
if (!d) return;
|
|
89
|
+
const dist = Math.max(0, e.clientY - d.y0);
|
|
90
|
+
const speed = dist / Math.max(1, performance.now() - d.t0);
|
|
91
|
+
const height = sheet.current?.offsetHeight ?? 0;
|
|
92
|
+
const limit = height > 0 ? Math.min(CLOSE_MAX, height * CLOSE_SHARE) : CLOSE_MAX;
|
|
93
|
+
setSettling(true);
|
|
94
|
+
if (dist > limit || (dist > 8 && speed > FLICK)) setOpen(false);
|
|
95
|
+
else setDy(0);
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
<DialogPrimitive.Root open={open} onOpenChange={setOpen}>
|
|
100
|
+
{trigger && <DialogPrimitive.Trigger asChild>{trigger}</DialogPrimitive.Trigger>}
|
|
101
|
+
<DialogPrimitive.Portal>
|
|
102
|
+
<DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-scrim" />
|
|
103
|
+
<DialogPrimitive.Content
|
|
104
|
+
ref={sheet}
|
|
105
|
+
{...(description ? {} : { 'aria-describedby': undefined })}
|
|
106
|
+
onOpenAutoFocus={(e) => {
|
|
107
|
+
onOpenAutoFocus?.(e);
|
|
108
|
+
if (e.defaultPrevented) return;
|
|
109
|
+
// The sheet itself takes focus (read out by its title), not the first row.
|
|
110
|
+
e.preventDefault();
|
|
111
|
+
const el = e.currentTarget as HTMLElement | null;
|
|
112
|
+
el?.focus();
|
|
113
|
+
if (!reduce && el && typeof el.animate === 'function') {
|
|
114
|
+
el.animate([{ transform: 'translateY(100%)' }, { transform: 'translateY(0)' }], { duration: 240, easing: 'cubic-bezier(.16,1,.3,1)' });
|
|
115
|
+
}
|
|
116
|
+
}}
|
|
117
|
+
className={cn(
|
|
118
|
+
'fixed inset-x-0 bottom-0 z-50 flex max-h-[90dvh] flex-col rounded-t-[20px] bg-page px-4 pt-2 font-sans text-fg outline-none',
|
|
119
|
+
settling && !reduce && 'transition-transform duration-200 ease-out',
|
|
120
|
+
className,
|
|
121
|
+
)}
|
|
122
|
+
style={{
|
|
123
|
+
paddingBottom: 'max(30px, calc(env(safe-area-inset-bottom, 0px) + 12px))',
|
|
124
|
+
...(dy ? { transform: `translateY(${dy}px)` } : {}),
|
|
125
|
+
...style,
|
|
126
|
+
}}
|
|
127
|
+
{...rest}
|
|
128
|
+
>
|
|
129
|
+
<div
|
|
130
|
+
data-sheet-drag=""
|
|
131
|
+
className="flex shrink-0 cursor-grab touch-none flex-col select-none active:cursor-grabbing"
|
|
132
|
+
onPointerDown={onDown}
|
|
133
|
+
onPointerMove={onMove}
|
|
134
|
+
onPointerUp={onUp}
|
|
135
|
+
onPointerCancel={() => {
|
|
136
|
+
drag.current = null;
|
|
137
|
+
setSettling(true);
|
|
138
|
+
setDy(0);
|
|
139
|
+
}}
|
|
140
|
+
>
|
|
141
|
+
<span aria-hidden className="mb-2.5 h-1 w-9 self-center rounded-pill bg-rule" />
|
|
142
|
+
<DialogPrimitive.Title className={cn('m-0 px-1 pb-2 text-[17px] leading-[1.3] font-medium', hideTitle && 'sr-only')}>{title}</DialogPrimitive.Title>
|
|
143
|
+
{description && <DialogPrimitive.Description className="m-0 px-1 pb-2 text-small text-fg-secondary">{description}</DialogPrimitive.Description>}
|
|
144
|
+
</div>
|
|
145
|
+
<div className="flex min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto">{children}</div>
|
|
146
|
+
<DialogPrimitive.Close className="sr-only">{closeLabel}</DialogPrimitive.Close>
|
|
147
|
+
</DialogPrimitive.Content>
|
|
148
|
+
</DialogPrimitive.Portal>
|
|
149
|
+
</DialogPrimitive.Root>
|
|
150
|
+
);
|
|
151
|
+
}
|
|
@@ -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
|
+
}
|
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';
|