@cosxai/ui 1.0.0-alpha.1 → 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 +109 -0
- package/package.json +10 -4
- package/src/brand.css +1 -1
- package/src/components/ActionBar.tsx +607 -0
- package/src/components/ActivityTimeline.tsx +77 -0
- package/src/components/AppRail.tsx +141 -0
- package/src/components/AsyncSelect.tsx +205 -0
- package/src/components/AuthLayout.tsx +127 -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/BrandLoader.tsx +122 -0
- package/src/components/Breadcrumb.tsx +129 -0
- package/src/components/Button.tsx +48 -40
- package/src/components/ChoiceCards.tsx +68 -0
- package/src/components/CodeInput.tsx +150 -0
- package/src/components/CommandPalette.tsx +228 -0
- package/src/components/ConfirmDialog.tsx +294 -0
- package/src/components/CopyField.tsx +104 -0
- package/src/components/DateInput.tsx +366 -0
- package/src/components/Dialog.tsx +27 -3
- package/src/components/Dropzone.tsx +187 -0
- package/src/components/FileCard.tsx +233 -0
- package/src/components/FileRow.tsx +159 -0
- package/src/components/FolderTree.tsx +377 -0
- package/src/components/ListToolbar.tsx +177 -0
- package/src/components/MentionInput.tsx +233 -0
- package/src/components/Menu.tsx +109 -0
- package/src/components/PageHeader.tsx +62 -0
- package/src/components/PageState.tsx +84 -0
- package/src/components/Popover.tsx +46 -0
- package/src/components/Progress.tsx +97 -0
- package/src/components/PulseDot.tsx +31 -0
- package/src/components/RecipientsInput.tsx +252 -0
- package/src/components/SearchField.tsx +137 -0
- package/src/components/SegmentedControl.tsx +95 -0
- package/src/components/SidePanel.tsx +130 -0
- package/src/components/Skeleton.tsx +73 -0
- package/src/components/StageProgress.tsx +51 -0
- package/src/components/Steps.tsx +58 -0
- package/src/components/SyncStatus.tsx +56 -0
- package/src/components/ThemeSwitch.tsx +99 -0
- package/src/components/Toast.tsx +45 -8
- package/src/components/UploadCheck.tsx +118 -0
- package/src/components/UploadList.tsx +176 -0
- package/src/components/VirtualList.tsx +90 -0
- package/src/components/WindowDrop.tsx +131 -0
- package/src/components/WorkspaceRow.tsx +80 -0
- package/src/components/inputs-aria.ts +5 -0
- package/src/components/inputs-date.ts +92 -0
- package/src/components/inputs-listbox.tsx +154 -0
- package/src/components/inputs-search.ts +60 -0
- package/src/components/status-files.ts +124 -0
- package/src/components/status-motion.ts +30 -0
- package/src/components/useSelection.ts +142 -0
- package/src/index.ts +52 -1
- package/src/theme-base.css +123 -0
- package/src/theme-reset.css +14 -0
- package/src/theme.css +2 -125
- package/src/components/core.test.tsx +0 -242
- package/src/components/forms.test.tsx +0 -280
- package/src/components/overlays.test.tsx +0 -245
|
@@ -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,122 @@
|
|
|
1
|
+
import { useEffect, useId, useRef } from 'react';
|
|
2
|
+
|
|
3
|
+
import { LOOP } from './logo-art';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* BrandLoader — the COSX loop as a loading indicator (Claude Design, COSX
|
|
7
|
+
* Loader). A short stroke runs along the loop, blooms into the full mark,
|
|
8
|
+
* holds 0.6 s, gathers back and runs again: 3.4 s a round, the head's
|
|
9
|
+
* speed continuous. At rest it is the logo itself — the fill is the logo
|
|
10
|
+
* path, the stroke only a mask over it.
|
|
11
|
+
*
|
|
12
|
+
* For whole-page and start-up waits, where the brand belongs. Buttons,
|
|
13
|
+
* rows, inputs and anything under 24 px wide keep Spinner or a skeleton.
|
|
14
|
+
* Reduced motion: the full mark, still.
|
|
15
|
+
*/
|
|
16
|
+
export type BrandLoaderProps = {
|
|
17
|
+
/** Width in px (height follows the mark: 106 / 202). Default 48; 24 at least. */
|
|
18
|
+
size?: number | undefined;
|
|
19
|
+
/**
|
|
20
|
+
* ink on paper and yellow; linen or accent on ink; current follows the
|
|
21
|
+
* text colour (light and dark themes alike). The mark is never yellow on paper.
|
|
22
|
+
*/
|
|
23
|
+
tone?: 'ink' | 'linen' | 'accent' | 'current' | undefined;
|
|
24
|
+
/** The faint full mark under the stroke (default on), so it always reads as COSX. */
|
|
25
|
+
track?: boolean | undefined;
|
|
26
|
+
/** 1 = a 3.4 s round. */
|
|
27
|
+
speed?: number | undefined;
|
|
28
|
+
/** What is loading, for assistive tech. Default "Loading". */
|
|
29
|
+
label?: string | undefined;
|
|
30
|
+
className?: string | undefined;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const TONES = {
|
|
34
|
+
ink: ['#111111', 'rgba(17,17,17,.12)'],
|
|
35
|
+
linen: ['#F5F2EC', 'rgba(245,242,236,.16)'],
|
|
36
|
+
accent: ['#FFD166', 'rgba(255,209,102,.18)'],
|
|
37
|
+
current: ['currentColor', 'currentColor'],
|
|
38
|
+
} as const;
|
|
39
|
+
|
|
40
|
+
// The stroke's centre line: the C counter-clockwise, the diagonal, the O
|
|
41
|
+
// clockwise, then back across the open gap.
|
|
42
|
+
const R = 38.3;
|
|
43
|
+
const LC = [96.12, 144.62] as const;
|
|
44
|
+
const RC = [193.32, 144.62] as const;
|
|
45
|
+
const pt = (c: readonly [number, number], deg: number) => {
|
|
46
|
+
const a = (deg * Math.PI) / 180;
|
|
47
|
+
return `${(c[0] + R * Math.cos(a)).toFixed(2)},${(c[1] + R * Math.sin(a)).toFixed(2)}`;
|
|
48
|
+
};
|
|
49
|
+
const CENTRE = `M${pt(LC, -42)} A${R},${R} 0 1,0 ${pt(LC, 48)} L${pt(RC, 214)} A${R},${R} 0 0,1 ${pt(RC, 34)} A${R},${R} 0 0,1 ${pt(RC, 214)} L${pt(LC, -42)} Z`;
|
|
50
|
+
|
|
51
|
+
// Seconds: run, bloom, hold, gather; SEG = the running stroke's share of the loop.
|
|
52
|
+
const RUN = 1.5;
|
|
53
|
+
const GROW = 0.7;
|
|
54
|
+
const HOLD = 0.6;
|
|
55
|
+
const SHRINK = 0.6;
|
|
56
|
+
const T = RUN + GROW + HOLD + SHRINK;
|
|
57
|
+
const SEG = 0.14;
|
|
58
|
+
const easeOut = (x: number) => 1 - (1 - x) ** 3;
|
|
59
|
+
const easeInOut = (x: number) => (x < 0.5 ? 4 * x ** 3 : 1 - (-2 * x + 2) ** 3 / 2);
|
|
60
|
+
|
|
61
|
+
/** Head position h (in loop lengths) and visible share s (0..1) at t seconds. */
|
|
62
|
+
export function loaderState(t: number): { h: number; s: number } {
|
|
63
|
+
const c = Math.floor(t / T);
|
|
64
|
+
let u = t - c * T;
|
|
65
|
+
const base = c * (RUN + GROW / 2 + SHRINK / 2);
|
|
66
|
+
if (u < RUN) return { h: base + u, s: SEG };
|
|
67
|
+
u -= RUN;
|
|
68
|
+
if (u < GROW) {
|
|
69
|
+
const g = u / GROW;
|
|
70
|
+
return { h: base + RUN + GROW * (g - (g * g) / 2), s: SEG + (1 - SEG) * easeOut(g) };
|
|
71
|
+
}
|
|
72
|
+
u -= GROW;
|
|
73
|
+
if (u < HOLD) return { h: base + RUN + GROW / 2, s: 1 };
|
|
74
|
+
u -= HOLD;
|
|
75
|
+
const k = u / SHRINK;
|
|
76
|
+
return { h: base + RUN + GROW / 2 + (SHRINK * k * k) / 2, s: 1 - (1 - SEG) * easeInOut(k) };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function BrandLoader({ size = 48, tone = 'ink', track = true, speed = 1, label = 'Loading', className }: BrandLoaderProps) {
|
|
80
|
+
const maskId = `cosx-loader-${useId().replace(/[^a-zA-Z0-9-]/g, '')}`;
|
|
81
|
+
const stroke = useRef<SVGPathElement>(null);
|
|
82
|
+
const [fill, faint] = TONES[tone];
|
|
83
|
+
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
const c = stroke.current;
|
|
86
|
+
if (!c || typeof c.getTotalLength !== 'function') return undefined;
|
|
87
|
+
const L = c.getTotalLength();
|
|
88
|
+
const still = window.matchMedia?.('(prefers-reduced-motion: reduce)');
|
|
89
|
+
const t0 = performance.now();
|
|
90
|
+
let raf = 0;
|
|
91
|
+
const frame = (now: number) => {
|
|
92
|
+
const st = still?.matches ? { h: 0, s: 1 } : loaderState(((now - t0) / 1000) * speed);
|
|
93
|
+
const s = Math.min(1, st.s);
|
|
94
|
+
const tail = (((st.h - s) % 1) + 1) % 1;
|
|
95
|
+
c.setAttribute('stroke-dasharray', s >= 0.999 ? `${L} 0` : `${(s * L).toFixed(2)} ${((1 - s) * L).toFixed(2)}`);
|
|
96
|
+
c.setAttribute('stroke-dashoffset', (-tail * L).toFixed(2));
|
|
97
|
+
raf = requestAnimationFrame(frame);
|
|
98
|
+
};
|
|
99
|
+
raf = requestAnimationFrame(frame);
|
|
100
|
+
return () => cancelAnimationFrame(raf);
|
|
101
|
+
}, [speed]);
|
|
102
|
+
|
|
103
|
+
return (
|
|
104
|
+
<svg
|
|
105
|
+
role="img"
|
|
106
|
+
aria-label={label}
|
|
107
|
+
viewBox="44 92 202 106"
|
|
108
|
+
width={size}
|
|
109
|
+
height={Number(((size * 106) / 202).toFixed(1))}
|
|
110
|
+
className={className}
|
|
111
|
+
style={{ display: 'block', overflow: 'visible' }}
|
|
112
|
+
>
|
|
113
|
+
<defs>
|
|
114
|
+
<mask id={maskId} maskUnits="userSpaceOnUse" x="30" y="80" width="230" height="130">
|
|
115
|
+
<path ref={stroke} d={CENTRE} fill="none" stroke="#fff" strokeWidth="21" strokeLinecap="round" strokeLinejoin="round" />
|
|
116
|
+
</mask>
|
|
117
|
+
</defs>
|
|
118
|
+
{track && <path d={LOOP.paths[0]} fill={faint} fillOpacity={tone === 'current' ? 0.12 : undefined} />}
|
|
119
|
+
<path d={LOOP.paths[0]} fill={fill} mask={`url(#${maskId})`} />
|
|
120
|
+
</svg>
|
|
121
|
+
);
|
|
122
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
|
|
2
|
+
import type { ComponentProps, ReactNode } from 'react';
|
|
3
|
+
|
|
4
|
+
import { cn } from '../lib/cn';
|
|
5
|
+
|
|
6
|
+
export type BreadcrumbItem = {
|
|
7
|
+
label: ReactNode;
|
|
8
|
+
/** A link; or use onSelect for in-app navigation. */
|
|
9
|
+
href?: string | undefined;
|
|
10
|
+
onSelect?: (() => void) | undefined;
|
|
11
|
+
/** A trailing tag on the current page: its format (PDF). */
|
|
12
|
+
tag?: ReactNode;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type BreadcrumbProps = Omit<ComponentProps<'nav'>, 'children'> & {
|
|
16
|
+
items: BreadcrumbItem[];
|
|
17
|
+
/** What starts the trail — the workspace brand (on a customer domain it replaces the platform name). */
|
|
18
|
+
root?: ReactNode;
|
|
19
|
+
/** Beyond this many segments the middle folds into a menu. @default 4 */
|
|
20
|
+
max?: number | undefined;
|
|
21
|
+
/** @default "Breadcrumb" */
|
|
22
|
+
label?: string | undefined;
|
|
23
|
+
/** Accessible name of the fold button. @default "Show hidden folders" */
|
|
24
|
+
foldLabel?: string | undefined;
|
|
25
|
+
/** A line under the trail: the current item's status. */
|
|
26
|
+
status?: ReactNode;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const linkClass = 'cursor-pointer text-fg-secondary outline-none hover:text-fg hover:underline underline-offset-2 focus-visible:shadow-(--focus-ring) rounded-xs';
|
|
30
|
+
|
|
31
|
+
function Crumb({ item }: { item: BreadcrumbItem }) {
|
|
32
|
+
if (item.href)
|
|
33
|
+
return (
|
|
34
|
+
<a href={item.href} onClick={item.onSelect} className={linkClass}>
|
|
35
|
+
{item.label}
|
|
36
|
+
</a>
|
|
37
|
+
);
|
|
38
|
+
if (item.onSelect)
|
|
39
|
+
return (
|
|
40
|
+
<button type="button" onClick={item.onSelect} className={linkClass}>
|
|
41
|
+
{item.label}
|
|
42
|
+
</button>
|
|
43
|
+
);
|
|
44
|
+
return <span className="text-fg-secondary">{item.label}</span>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const Sep = () => (
|
|
48
|
+
<span aria-hidden className="text-fg-secondary">
|
|
49
|
+
/
|
|
50
|
+
</span>
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Breadcrumb — where this page sits. The current page is the last segment
|
|
55
|
+
* and not a link; beyond four segments the middle folds into a menu. On a
|
|
56
|
+
* customer domain the trail starts at the workspace brand (root).
|
|
57
|
+
*/
|
|
58
|
+
export function Breadcrumb({ items, root, max = 4, label = 'Breadcrumb', foldLabel = 'Show hidden folders', status, className, ...rest }: BreadcrumbProps) {
|
|
59
|
+
const last = items[items.length - 1];
|
|
60
|
+
const before = items.slice(0, -1);
|
|
61
|
+
const total = items.length + (root ? 1 : 0);
|
|
62
|
+
const fold = total > max && before.length > 1;
|
|
63
|
+
// Folded: [root or the first ancestor] / … / parent / current.
|
|
64
|
+
const head = fold ? (root ? [] : before.slice(0, 1)) : before;
|
|
65
|
+
const folded = fold ? before.slice(root ? 0 : 1, -1) : [];
|
|
66
|
+
const tail = fold ? before.slice(-1) : [];
|
|
67
|
+
const segment = 'flex items-center gap-2';
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<nav aria-label={label} className={cn('flex flex-col gap-1.5', className)} {...rest}>
|
|
71
|
+
<ol className="m-0 flex list-none flex-wrap items-center gap-x-2 gap-y-1 p-0 text-ui">
|
|
72
|
+
{root && (
|
|
73
|
+
<li className={segment}>
|
|
74
|
+
{root}
|
|
75
|
+
{items.length > 0 && <Sep />}
|
|
76
|
+
</li>
|
|
77
|
+
)}
|
|
78
|
+
{head.map((it, i) => (
|
|
79
|
+
<li key={`h${i}`} className={segment}>
|
|
80
|
+
<Crumb item={it} />
|
|
81
|
+
<Sep />
|
|
82
|
+
</li>
|
|
83
|
+
))}
|
|
84
|
+
{folded.length > 0 && (
|
|
85
|
+
<li className={segment}>
|
|
86
|
+
<DropdownMenu.Root>
|
|
87
|
+
<DropdownMenu.Trigger
|
|
88
|
+
aria-label={foldLabel}
|
|
89
|
+
className="inline-grid h-6 min-w-6 cursor-pointer place-items-center rounded-sm bg-sunk px-1.5 text-meta font-semibold text-fg outline-none hover:bg-well focus-visible:shadow-(--focus-ring)"
|
|
90
|
+
>
|
|
91
|
+
…
|
|
92
|
+
</DropdownMenu.Trigger>
|
|
93
|
+
<DropdownMenu.Portal>
|
|
94
|
+
<DropdownMenu.Content align="start" sideOffset={6} className="z-50 min-w-[180px] rounded-lg border border-rule bg-page p-1.5 text-ui text-fg">
|
|
95
|
+
{folded.map((it, i) => (
|
|
96
|
+
<DropdownMenu.Item
|
|
97
|
+
key={i}
|
|
98
|
+
onSelect={() => {
|
|
99
|
+
if (it.href) window.location.assign(it.href);
|
|
100
|
+
else it.onSelect?.();
|
|
101
|
+
}}
|
|
102
|
+
className="flex h-8 cursor-pointer items-center rounded-md px-2.5 outline-none data-[highlighted]:bg-hover"
|
|
103
|
+
>
|
|
104
|
+
{it.label}
|
|
105
|
+
</DropdownMenu.Item>
|
|
106
|
+
))}
|
|
107
|
+
</DropdownMenu.Content>
|
|
108
|
+
</DropdownMenu.Portal>
|
|
109
|
+
</DropdownMenu.Root>
|
|
110
|
+
<Sep />
|
|
111
|
+
</li>
|
|
112
|
+
)}
|
|
113
|
+
{tail.map((it, i) => (
|
|
114
|
+
<li key={`t${i}`} className={segment}>
|
|
115
|
+
<Crumb item={it} />
|
|
116
|
+
<Sep />
|
|
117
|
+
</li>
|
|
118
|
+
))}
|
|
119
|
+
{last && (
|
|
120
|
+
<li aria-current="page" className="flex items-center gap-2 font-medium text-fg">
|
|
121
|
+
{last.label}
|
|
122
|
+
{last.tag && <span className="rounded-sm bg-sunk px-1.5 py-0.5 text-[11px] font-semibold text-fg">{last.tag}</span>}
|
|
123
|
+
</li>
|
|
124
|
+
)}
|
|
125
|
+
</ol>
|
|
126
|
+
{status && <div className="text-meta text-fg-secondary">{status}</div>}
|
|
127
|
+
</nav>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Slot } from '@radix-ui/react-slot';
|
|
2
2
|
import { cva } from 'class-variance-authority';
|
|
3
3
|
import { Check } from 'lucide-react';
|
|
4
|
-
import {
|
|
4
|
+
import type { ComponentProps, ReactNode } from 'react';
|
|
5
5
|
|
|
6
6
|
import { cn } from '../lib/cn';
|
|
7
7
|
import { Spinner } from './Spinner';
|
|
@@ -17,7 +17,9 @@ const button = cva(
|
|
|
17
17
|
'rounded-md border border-transparent font-sans font-semibold leading-none',
|
|
18
18
|
'transition-[background-color,border-color,color] duration-[120ms] ease-standard',
|
|
19
19
|
'cursor-pointer outline-none focus-visible:shadow-(--focus-ring)',
|
|
20
|
-
|
|
20
|
+
// Disabled sinks: the well with grey text (3.0 readme and design.cosx.co;
|
|
21
|
+
// the 3.0 Button source used opacity). "!" so hover never lifts it back.
|
|
22
|
+
'disabled:cursor-not-allowed aria-disabled:cursor-not-allowed',
|
|
21
23
|
],
|
|
22
24
|
{
|
|
23
25
|
variants: {
|
|
@@ -45,6 +47,17 @@ const button = cva(
|
|
|
45
47
|
// Focus ring follows the ground.
|
|
46
48
|
{ ground: 'yellow', class: 'focus-visible:shadow-[0_0_0_3px_var(--yellow),0_0_0_5px_var(--ink)]' },
|
|
47
49
|
{ ground: 'ink', class: 'focus-visible:shadow-[0_0_0_3px_var(--ink),0_0_0_5px_var(--yellow)]' },
|
|
50
|
+
// Disabled: filled types sink into the well; outlined and text-only turn grey.
|
|
51
|
+
{
|
|
52
|
+
variant: ['primary', 'yellow', 'ink', 'danger'],
|
|
53
|
+
class:
|
|
54
|
+
'disabled:border-transparent! disabled:bg-well! disabled:text-fg-secondary! aria-disabled:border-transparent! aria-disabled:bg-well! aria-disabled:text-fg-secondary!',
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
variant: ['secondary', 'ghost'],
|
|
58
|
+
class:
|
|
59
|
+
'disabled:bg-transparent! disabled:text-fg-secondary! aria-disabled:bg-transparent! aria-disabled:text-fg-secondary! disabled:hover:border-[rgba(17,17,17,.18)] aria-disabled:hover:border-[rgba(17,17,17,.18)]',
|
|
60
|
+
},
|
|
48
61
|
],
|
|
49
62
|
defaultVariants: { size: 'md', variant: 'primary', ground: 'auto' },
|
|
50
63
|
},
|
|
@@ -63,7 +76,7 @@ export type ButtonProps = Omit<ComponentProps<'button'>, 'disabled'> & {
|
|
|
63
76
|
shortcut?: string | undefined;
|
|
64
77
|
/** A count at the end (Comments 12). Hidden at zero, 99+ above 99. */
|
|
65
78
|
count?: number | undefined;
|
|
66
|
-
/** Async state: busy
|
|
79
|
+
/** Async state: busy shows a spinner, done confirms in place; the width never changes. See useButtonAction. */
|
|
67
80
|
state?: ButtonState | undefined;
|
|
68
81
|
/** Label shown while state="done". @default the children */
|
|
69
82
|
doneLabel?: ReactNode;
|
|
@@ -106,15 +119,6 @@ export function Button({
|
|
|
106
119
|
ref,
|
|
107
120
|
...rest
|
|
108
121
|
}: ButtonProps) {
|
|
109
|
-
const inner = useRef<HTMLButtonElement | null>(null);
|
|
110
|
-
const [lockedWidth, setLockedWidth] = useState<number | null>(null);
|
|
111
|
-
|
|
112
|
-
// Lock the idle width so busy / done never resize the button.
|
|
113
|
-
useLayoutEffect(() => {
|
|
114
|
-
if (state === 'idle') setLockedWidth(null);
|
|
115
|
-
else if (lockedWidth === null && inner.current) setLockedWidth(inner.current.getBoundingClientRect().width);
|
|
116
|
-
}, [state, lockedWidth]);
|
|
117
|
-
|
|
118
122
|
const classes = cn(button({ variant, size, ground: ground ?? 'auto' }), className);
|
|
119
123
|
if (asChild) {
|
|
120
124
|
return (
|
|
@@ -125,23 +129,22 @@ export function Button({
|
|
|
125
129
|
}
|
|
126
130
|
|
|
127
131
|
const busy = state === 'busy';
|
|
132
|
+
// Busy and done keep the idle content in place (hidden) and draw on top of
|
|
133
|
+
// it, so the button never resizes — also when it first renders busy.
|
|
134
|
+
const overlaid = state !== 'idle';
|
|
128
135
|
const blocked = Boolean(disabledReason) && disabled;
|
|
129
136
|
const shownCount = count === undefined ? null : formatCount(count);
|
|
130
|
-
const setRefs = (el: HTMLButtonElement | null) => {
|
|
131
|
-
inner.current = el;
|
|
132
|
-
if (typeof ref === 'function') ref(el);
|
|
133
|
-
else if (ref) ref.current = el;
|
|
134
|
-
};
|
|
135
|
-
|
|
136
137
|
return (
|
|
137
138
|
<button
|
|
138
139
|
type="button"
|
|
139
140
|
{...rest}
|
|
140
|
-
ref={
|
|
141
|
+
ref={ref}
|
|
141
142
|
className={classes}
|
|
142
|
-
style={
|
|
143
|
+
style={style}
|
|
143
144
|
disabled={disabled && !disabledReason ? true : undefined}
|
|
144
|
-
|
|
145
|
+
// Busy is not disabled (spec: "busy and disabled are different states"):
|
|
146
|
+
// it keeps its look; aria-busy says so and repeat clicks are ignored below.
|
|
147
|
+
aria-disabled={blocked ? true : undefined}
|
|
145
148
|
aria-busy={busy || undefined}
|
|
146
149
|
title={blocked ? disabledReason : rest.title}
|
|
147
150
|
onClick={(e) => {
|
|
@@ -152,27 +155,32 @@ export function Button({
|
|
|
152
155
|
onClick?.(e);
|
|
153
156
|
}}
|
|
154
157
|
>
|
|
155
|
-
{
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
{
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
158
|
+
<span className={cn('contents', overlaid && 'invisible')} aria-hidden={overlaid || undefined} data-button-content="">
|
|
159
|
+
{iconLeft}
|
|
160
|
+
{children}
|
|
161
|
+
{iconRight}
|
|
162
|
+
{shownCount && <span className="font-medium tabular-nums opacity-70">{shownCount}</span>}
|
|
163
|
+
{shortcut && (
|
|
164
|
+
<kbd className="ml-0.5 font-sans text-[12px] font-medium opacity-60" aria-hidden>
|
|
165
|
+
{shortcut}
|
|
166
|
+
</kbd>
|
|
167
|
+
)}
|
|
168
|
+
</span>
|
|
169
|
+
{overlaid && (
|
|
170
|
+
<span className="absolute inset-0 flex items-center justify-center gap-2" data-button-overlay="">
|
|
171
|
+
{busy ? (
|
|
172
|
+
<>
|
|
173
|
+
<Spinner />
|
|
174
|
+
<span className="sr-only">{children}</span>
|
|
175
|
+
</>
|
|
176
|
+
) : (
|
|
177
|
+
<>
|
|
178
|
+
<Check size={14} strokeWidth={2} aria-hidden />
|
|
179
|
+
{doneLabel ?? children}
|
|
180
|
+
</>
|
|
172
181
|
)}
|
|
173
|
-
|
|
182
|
+
</span>
|
|
174
183
|
)}
|
|
175
|
-
{busy && <span className="sr-only">{children}</span>}
|
|
176
184
|
</button>
|
|
177
185
|
);
|
|
178
186
|
}
|