panelui-web 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/avatar/index.d.ts +66 -0
- package/dist/components/avatar/index.js +251 -0
- package/dist/components/flow/index.d.ts +26 -4
- package/dist/components/flow/index.js +81 -20
- package/dist/components/label/index.d.ts +89 -0
- package/dist/components/label/index.js +64 -0
- package/dist/components/message/index.d.ts +161 -0
- package/dist/components/message/index.js +295 -0
- package/dist/components/sheet/index.d.ts +43 -4
- package/dist/components/sheet/index.js +78 -10
- package/dist/components/switch/index.d.ts +51 -0
- package/dist/components/switch/index.js +88 -0
- package/dist/components/tabs/index.d.ts +94 -0
- package/dist/components/tabs/index.js +196 -0
- package/dist/index.d.ts +6 -1
- package/dist/index.js +6 -1
- package/package.json +1 -1
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { type ComponentProps, type ReactNode } from 'react';
|
|
2
|
+
export type AvatarSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
|
|
3
|
+
export type AvatarShape = 'circle' | 'square';
|
|
4
|
+
export type AvatarColor = 'chart-1' | 'chart-2' | 'chart-3' | 'chart-4' | 'chart-5' | 'muted';
|
|
5
|
+
export type AvatarStatus = 'online' | 'away' | 'busy' | 'offline';
|
|
6
|
+
export interface AvatarProps extends Omit<ComponentProps<'span'>, 'color'> {
|
|
7
|
+
/** The picture. Without it, or until it has loaded, the initials show. */
|
|
8
|
+
src?: string;
|
|
9
|
+
/** Who it is. Gives the initials, the colour and what a screen reader says. */
|
|
10
|
+
name?: string;
|
|
11
|
+
/** Read out in place of `name`, when that is not the right thing to say. */
|
|
12
|
+
alt?: string;
|
|
13
|
+
/** Default `md`. In a group, the group's size. */
|
|
14
|
+
size?: AvatarSize;
|
|
15
|
+
/** `circle` for people, `square` for teams, projects and things. Default `circle`. */
|
|
16
|
+
shape?: AvatarShape;
|
|
17
|
+
/** The tint behind the initials. Picked from `name` when left out. */
|
|
18
|
+
color?: AvatarColor;
|
|
19
|
+
/** In place of the initials, such as an icon for a team or a bot. */
|
|
20
|
+
fallback?: ReactNode;
|
|
21
|
+
/** An `Avatar.Badge`. */
|
|
22
|
+
children?: ReactNode;
|
|
23
|
+
}
|
|
24
|
+
/** A picture of someone, or their initials in a colour of their own. */
|
|
25
|
+
declare function AvatarRoot({ src, name, alt, size, shape, color, fallback, className, style, children, ...props }: AvatarProps): import("react").JSX.Element;
|
|
26
|
+
declare namespace AvatarRoot {
|
|
27
|
+
var displayName: string;
|
|
28
|
+
}
|
|
29
|
+
export interface AvatarBadgeProps extends Omit<ComponentProps<'span'>, 'color'> {
|
|
30
|
+
/** A presence dot in the colour that says it. Leave out for a badge of your own. */
|
|
31
|
+
status?: AvatarStatus;
|
|
32
|
+
/** For a badge with an icon in it: the fill behind the icon. */
|
|
33
|
+
color?: AvatarColor;
|
|
34
|
+
/** What a screen reader says after the name. Defaults to the status. */
|
|
35
|
+
label?: string;
|
|
36
|
+
/** An icon, such as a check for a verified account. */
|
|
37
|
+
children?: ReactNode;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* A dot or a small icon on the avatar's lower trailing edge. The avatar is
|
|
41
|
+
* cut away round it, so it reads on any background.
|
|
42
|
+
*/
|
|
43
|
+
declare function AvatarBadge({ status, color, label: _label, className, ...props }: AvatarBadgeProps): import("react").JSX.Element;
|
|
44
|
+
declare namespace AvatarBadge {
|
|
45
|
+
var displayName: string;
|
|
46
|
+
}
|
|
47
|
+
export interface AvatarGroupProps extends ComponentProps<'div'> {
|
|
48
|
+
/** How many to show. The rest are counted in a last circle that lists them. */
|
|
49
|
+
max?: number;
|
|
50
|
+
/** The size of every avatar in it. Default `md`. */
|
|
51
|
+
size?: AvatarSize;
|
|
52
|
+
/** The shape of every avatar in it. Default `circle`. */
|
|
53
|
+
shape?: AvatarShape;
|
|
54
|
+
/** What a screen reader calls the group, such as "Members". */
|
|
55
|
+
label?: string;
|
|
56
|
+
}
|
|
57
|
+
/** A row of overlapping avatars, that opens up when it is hovered or focused. */
|
|
58
|
+
declare function AvatarGroup({ max, size, shape, label, className, style, children, ...props }: AvatarGroupProps): import("react").JSX.Element;
|
|
59
|
+
declare namespace AvatarGroup {
|
|
60
|
+
var displayName: string;
|
|
61
|
+
}
|
|
62
|
+
export declare const Avatar: typeof AvatarRoot & {
|
|
63
|
+
Badge: typeof AvatarBadge;
|
|
64
|
+
Group: typeof AvatarGroup;
|
|
65
|
+
};
|
|
66
|
+
export { AvatarBadge, AvatarGroup };
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
/**
|
|
4
|
+
* Avatar — a person or a team as a picture, with their initials when there
|
|
5
|
+
* is no picture to show.
|
|
6
|
+
*
|
|
7
|
+
* ```tsx
|
|
8
|
+
* <Avatar src="/noor.jpg" name="Noor Haddad" />
|
|
9
|
+
*
|
|
10
|
+
* <Avatar name="Tomás Ferreira" size="lg">
|
|
11
|
+
* <Avatar.Badge status="online" />
|
|
12
|
+
* </Avatar>
|
|
13
|
+
*
|
|
14
|
+
* <Avatar.Group max={4}>
|
|
15
|
+
* {people.map((person) => <Avatar key={person.id} src={person.photo} name={person.name} />)}
|
|
16
|
+
* </Avatar.Group>
|
|
17
|
+
* ```
|
|
18
|
+
*
|
|
19
|
+
* ## Initials in a colour of their own
|
|
20
|
+
*
|
|
21
|
+
* Without a picture, or while one loads, the avatar shows the initials of
|
|
22
|
+
* `name` on a tint of one of the theme's chart colours. The colour is picked
|
|
23
|
+
* from the name, so the same person has the same colour on every screen, and
|
|
24
|
+
* a row of people without pictures can still be told apart at a glance.
|
|
25
|
+
*
|
|
26
|
+
* ## Cut out, not ringed
|
|
27
|
+
*
|
|
28
|
+
* A badge, and the avatar overlapping the next one in a group, are not drawn
|
|
29
|
+
* with a ring in the page's background colour. The avatar underneath is cut
|
|
30
|
+
* away with a mask instead, so the gap round them is a real gap: it shows
|
|
31
|
+
* whatever the avatar sits on, a card, a tinted row, a photograph, and needs
|
|
32
|
+
* no colour to match it.
|
|
33
|
+
*
|
|
34
|
+
* ## A group opens up
|
|
35
|
+
*
|
|
36
|
+
* Avatars in a group overlap. Hovered or focused, they move apart, and the cut
|
|
37
|
+
* in each moves with the one beside it. Past `max`, the rest are counted in a
|
|
38
|
+
* last circle that opens a list of who they are.
|
|
39
|
+
*/
|
|
40
|
+
import { Avatar as BaseAvatar } from '@base-ui/react/avatar';
|
|
41
|
+
import { Popover } from '@base-ui/react/popover';
|
|
42
|
+
import { Children, createContext, isValidElement, useContext, useMemo, } from 'react';
|
|
43
|
+
import { tv } from 'tailwind-variants';
|
|
44
|
+
const SIZE = {
|
|
45
|
+
xs: '1.5rem',
|
|
46
|
+
sm: '2rem',
|
|
47
|
+
md: '2.5rem',
|
|
48
|
+
lg: '3.5rem',
|
|
49
|
+
xl: '4.5rem',
|
|
50
|
+
};
|
|
51
|
+
const COLORS = ['chart-1', 'chart-2', 'chart-3', 'chart-4', 'chart-5'];
|
|
52
|
+
const avatarVariants = tv({
|
|
53
|
+
slots: {
|
|
54
|
+
root: 'relative inline-flex size-(--avatar-size) shrink-0 align-middle',
|
|
55
|
+
/*
|
|
56
|
+
* The face is what gets cut. Its hairline is inside it, so the cut takes
|
|
57
|
+
* the hairline with it and no stray arc of it is left in the gap.
|
|
58
|
+
*/
|
|
59
|
+
face: [
|
|
60
|
+
'relative flex size-full overflow-hidden bg-muted select-none',
|
|
61
|
+
"after:pointer-events-none after:absolute after:inset-0 after:rounded-[inherit] after:ring-1 after:ring-inset after:ring-foreground/10 after:content-['']",
|
|
62
|
+
'[mask-repeat:no-repeat] [mask-composite:intersect]',
|
|
63
|
+
],
|
|
64
|
+
image: 'size-full object-cover',
|
|
65
|
+
fallback: [
|
|
66
|
+
'flex size-full items-center justify-center font-medium leading-none tracking-tight',
|
|
67
|
+
'text-[calc(var(--avatar-size)*0.38)]',
|
|
68
|
+
'bg-[color-mix(in_oklab,var(--avatar-color)_22%,var(--card))] text-[color-mix(in_oklab,var(--avatar-color)_70%,var(--foreground))]',
|
|
69
|
+
'[&_svg]:size-[55%] [&_svg]:opacity-70',
|
|
70
|
+
],
|
|
71
|
+
badge: [
|
|
72
|
+
'absolute flex size-(--badge-size) items-center justify-center rounded-full',
|
|
73
|
+
'left-[calc(var(--badge-center)-var(--badge-size)/2)] top-[calc(var(--badge-center)-var(--badge-size)/2)]',
|
|
74
|
+
'text-[calc(var(--badge-size)*0.6)] [&_svg]:size-[70%]',
|
|
75
|
+
],
|
|
76
|
+
group: [
|
|
77
|
+
'group/avatars inline-flex items-center [--avatar-overlap:0.22]',
|
|
78
|
+
// Hovered or focused, the group opens up so each face can be seen whole.
|
|
79
|
+
'hover:[--avatar-overlap:0.08] has-focus-visible:[--avatar-overlap:0.08] motion-reduce:hover:[--avatar-overlap:0.22]',
|
|
80
|
+
],
|
|
81
|
+
item: [
|
|
82
|
+
'not-first:ms-[calc(var(--avatar-size)*var(--avatar-overlap)*-1)]',
|
|
83
|
+
'transition-[margin] duration-300 ease-[cubic-bezier(0.2,0,0,1)] motion-reduce:transition-none',
|
|
84
|
+
// The face's cut follows the margin, in step with it.
|
|
85
|
+
'*:data-[slot=avatar-face]:transition-[mask-position] *:data-[slot=avatar-face]:duration-300 *:data-[slot=avatar-face]:ease-[cubic-bezier(0.2,0,0,1)]',
|
|
86
|
+
],
|
|
87
|
+
count: [
|
|
88
|
+
'flex size-full cursor-pointer items-center justify-center bg-muted font-medium tabular-nums text-muted-foreground outline-none',
|
|
89
|
+
'text-[calc(var(--avatar-size)*0.34)] transition-colors hover:bg-accent hover:text-foreground',
|
|
90
|
+
'focus-visible:ring-2 focus-visible:ring-ring data-popup-open:bg-accent data-popup-open:text-foreground',
|
|
91
|
+
],
|
|
92
|
+
popup: [
|
|
93
|
+
'z-50 min-w-52 origin-(--transform-origin) rounded-xl border bg-popover p-1 text-popover-foreground shadow-lg outline-none',
|
|
94
|
+
'max-h-(--available-height) overflow-y-auto',
|
|
95
|
+
'transition-[opacity,scale] duration-150 ease-out data-starting-style:scale-95 data-starting-style:opacity-0 data-ending-style:scale-95 data-ending-style:opacity-0',
|
|
96
|
+
'motion-reduce:transition-none',
|
|
97
|
+
],
|
|
98
|
+
row: 'flex items-center gap-2.5 rounded-lg px-2 py-1.5 text-sm',
|
|
99
|
+
},
|
|
100
|
+
variants: {
|
|
101
|
+
shape: {
|
|
102
|
+
circle: { face: 'rounded-full', root: '[--badge-center:85.4%]' },
|
|
103
|
+
// The badge sits further in on a square, whose corner is further out.
|
|
104
|
+
square: { face: 'rounded-[28%]', root: '[--badge-center:calc(100%-var(--badge-size)*0.3)]' },
|
|
105
|
+
},
|
|
106
|
+
status: {
|
|
107
|
+
online: { badge: 'bg-success' },
|
|
108
|
+
away: { badge: 'bg-warning' },
|
|
109
|
+
busy: { badge: 'bg-destructive' },
|
|
110
|
+
// Hollow: there, but not reachable.
|
|
111
|
+
offline: { badge: 'border-[max(1.5px,calc(var(--badge-size)*0.2))] border-muted-foreground/70' },
|
|
112
|
+
},
|
|
113
|
+
color: {
|
|
114
|
+
'chart-1': { root: '[--avatar-color:var(--chart-1)]', badge: 'bg-chart-1 text-background' },
|
|
115
|
+
'chart-2': { root: '[--avatar-color:var(--chart-2)]', badge: 'bg-chart-2 text-background' },
|
|
116
|
+
'chart-3': { root: '[--avatar-color:var(--chart-3)]', badge: 'bg-chart-3 text-background' },
|
|
117
|
+
'chart-4': { root: '[--avatar-color:var(--chart-4)]', badge: 'bg-chart-4 text-background' },
|
|
118
|
+
'chart-5': { root: '[--avatar-color:var(--chart-5)]', badge: 'bg-chart-5 text-background' },
|
|
119
|
+
muted: { root: '[--avatar-color:var(--muted-foreground)]', badge: 'bg-foreground text-background' },
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
defaultVariants: { shape: 'circle' },
|
|
123
|
+
});
|
|
124
|
+
/** The same name always lands on the same colour. */
|
|
125
|
+
function colorOf(name) {
|
|
126
|
+
if (!name)
|
|
127
|
+
return 'muted';
|
|
128
|
+
let hash = 0;
|
|
129
|
+
for (const char of name)
|
|
130
|
+
hash = (hash * 31 + char.codePointAt(0)) | 0;
|
|
131
|
+
return COLORS[Math.abs(hash) % COLORS.length];
|
|
132
|
+
}
|
|
133
|
+
/** First letter of the first and last words: "Noor al-Haddad" is "NA". */
|
|
134
|
+
function initialsOf(name) {
|
|
135
|
+
const words = name.trim().split(/\s+/).filter(Boolean);
|
|
136
|
+
if (words.length === 0)
|
|
137
|
+
return '';
|
|
138
|
+
const first = [...words[0]][0] ?? '';
|
|
139
|
+
const last = words.length > 1 ? ([...words[words.length - 1]][0] ?? '') : '';
|
|
140
|
+
return (first + last).toUpperCase();
|
|
141
|
+
}
|
|
142
|
+
const STATUS_LABEL = {
|
|
143
|
+
online: 'online',
|
|
144
|
+
away: 'away',
|
|
145
|
+
busy: 'busy',
|
|
146
|
+
offline: 'offline',
|
|
147
|
+
};
|
|
148
|
+
/* -------------------------------------------------------------------------- */
|
|
149
|
+
/* Masks */
|
|
150
|
+
/* -------------------------------------------------------------------------- */
|
|
151
|
+
/*
|
|
152
|
+
* Every face has two mask layers, intersected: one for the badge, one for the
|
|
153
|
+
* avatar overlapping it in a group. A layer with nothing to cut is solid.
|
|
154
|
+
*
|
|
155
|
+
* Each cut is a gradient drawn in a box bigger than the face and placed with
|
|
156
|
+
* `mask-position`, rather than a gradient placed by its own coordinates. A
|
|
157
|
+
* position animates; a gradient's coordinates do not. That is what lets the
|
|
158
|
+
* cut slide along with the next avatar as the group opens.
|
|
159
|
+
*/
|
|
160
|
+
const SOLID = 'linear-gradient(#000,#000)';
|
|
161
|
+
const GAP = 'max(1.5px, calc(var(--avatar-size) * 0.05))';
|
|
162
|
+
/** A hole round the badge. */
|
|
163
|
+
const BADGE_CUT = `radial-gradient(circle at var(--badge-center) var(--badge-center), transparent calc(var(--badge-size) / 2 + ${GAP}), #000 calc(var(--badge-size) / 2 + ${GAP} + 0.5px))`;
|
|
164
|
+
/**
|
|
165
|
+
* A hole where the next avatar lies over this one. The box is three faces
|
|
166
|
+
* wide with the hole at its centre, then shifted so the centre lands on the
|
|
167
|
+
* next avatar's centre: `size × (1 − overlap)` along.
|
|
168
|
+
*/
|
|
169
|
+
const CIRCLE_CUT = `radial-gradient(circle, transparent calc(var(--avatar-size) / 2 + ${GAP}), #000 calc(var(--avatar-size) / 2 + ${GAP} + 0.5px))`;
|
|
170
|
+
const CIRCLE_CUT_SIZE = 'calc(var(--avatar-size) * 3) calc(var(--avatar-size) * 3)';
|
|
171
|
+
const CIRCLE_CUT_AT = 'calc(var(--avatar-size) * var(--avatar-overlap) * -1) calc(var(--avatar-size) * -1)';
|
|
172
|
+
/**
|
|
173
|
+
* A square's hole is the next square's own shape, a rounded square a little
|
|
174
|
+
* larger than it, drawn in the same three-face box so it moves the same way.
|
|
175
|
+
* A gradient cannot draw a rounded square, so this one is an SVG: 100 units
|
|
176
|
+
* to a face, with the corner at the face's 28% and the gap at 5%.
|
|
177
|
+
*/
|
|
178
|
+
const SQUARE_CUT = `url("data:image/svg+xml,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 300" preserveAspectRatio="none"><path fill-rule="evenodd" d="M0 0H300V300H0Z M128 95H172A33 33 0 0 1 205 128V172A33 33 0 0 1 172 205H128A33 33 0 0 1 95 172V128A33 33 0 0 1 128 95Z"/></svg>')}")`;
|
|
179
|
+
function maskStyle(shape, badge, overlapped) {
|
|
180
|
+
if (!badge && !overlapped)
|
|
181
|
+
return undefined;
|
|
182
|
+
const layers = [
|
|
183
|
+
badge ? [BADGE_CUT, '100% 100%', '0 0'] : [SOLID, '100% 100%', '0 0'],
|
|
184
|
+
overlapped
|
|
185
|
+
? shape === 'circle'
|
|
186
|
+
? [CIRCLE_CUT, CIRCLE_CUT_SIZE, CIRCLE_CUT_AT]
|
|
187
|
+
: [SQUARE_CUT, CIRCLE_CUT_SIZE, CIRCLE_CUT_AT]
|
|
188
|
+
: [SOLID, '100% 100%', '0 0'],
|
|
189
|
+
];
|
|
190
|
+
return {
|
|
191
|
+
maskImage: layers.map((layer) => layer[0]).join(', '),
|
|
192
|
+
maskSize: layers.map((layer) => layer[1]).join(', '),
|
|
193
|
+
maskPosition: layers.map((layer) => layer[2]).join(', '),
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
const GroupContext = createContext(null);
|
|
197
|
+
/** Set on every avatar in a group but the last, which nothing overlaps. */
|
|
198
|
+
const OverlappedContext = createContext(false);
|
|
199
|
+
/** A picture of someone, or their initials in a colour of their own. */
|
|
200
|
+
function AvatarRoot({ src, name, alt, size, shape, color, fallback, className, style, children, ...props }) {
|
|
201
|
+
const group = useContext(GroupContext);
|
|
202
|
+
const overlapped = useContext(OverlappedContext);
|
|
203
|
+
const resolvedSize = size ?? group?.size ?? 'md';
|
|
204
|
+
const resolvedShape = shape ?? group?.shape ?? 'circle';
|
|
205
|
+
const slots = avatarVariants({ shape: resolvedShape, color: color ?? colorOf(name) });
|
|
206
|
+
// A badge's status is part of who it is, so it is said with the name.
|
|
207
|
+
const badge = Children.toArray(children).find((child) => isValidElement(child) && child.type === AvatarBadge);
|
|
208
|
+
const status = badge?.props.label ?? (badge?.props.status ? STATUS_LABEL[badge.props.status] : undefined);
|
|
209
|
+
const label = [alt ?? name, status].filter(Boolean).join(', ') || undefined;
|
|
210
|
+
return (_jsxs("span", { "data-slot": "avatar", "data-size": resolvedSize, "data-shape": resolvedShape, role: label ? 'img' : undefined, "aria-label": label, className: slots.root({ className }), style: {
|
|
211
|
+
['--avatar-size']: SIZE[resolvedSize],
|
|
212
|
+
// Here and not on the badge, so the face's cut and the badge's place can both read it.
|
|
213
|
+
['--badge-size']: 'max(0.5rem, calc(var(--avatar-size) * 0.28))',
|
|
214
|
+
...style,
|
|
215
|
+
}, ...props, children: [_jsxs(BaseAvatar.Root, { "data-slot": "avatar-face", "aria-hidden": "true", className: slots.face(), style: maskStyle(resolvedShape, badge !== undefined, overlapped), children: [src ? _jsx(BaseAvatar.Image, { src: src, alt: "", className: slots.image() }) : null, _jsx(BaseAvatar.Fallback, { className: slots.fallback(), children: fallback ?? (name ? initialsOf(name) : _jsx(PersonGlyph, {})) })] }), children] }));
|
|
216
|
+
}
|
|
217
|
+
AvatarRoot.displayName = 'Avatar';
|
|
218
|
+
function PersonGlyph() {
|
|
219
|
+
return (_jsxs("svg", { viewBox: "0 0 24 24", fill: "currentColor", "aria-hidden": "true", children: [_jsx("circle", { cx: "12", cy: "8.5", r: "4" }), _jsx("path", { d: "M4 20.5c0-4 3.6-6.5 8-6.5s8 2.5 8 6.5z" })] }));
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* A dot or a small icon on the avatar's lower trailing edge. The avatar is
|
|
223
|
+
* cut away round it, so it reads on any background.
|
|
224
|
+
*/
|
|
225
|
+
function AvatarBadge({ status, color, label: _label, className, ...props }) {
|
|
226
|
+
const slots = avatarVariants({ status, color: status ? undefined : (color ?? 'muted') });
|
|
227
|
+
return (_jsx("span", { "aria-hidden": "true", "data-slot": "avatar-badge", "data-status": status, className: slots.badge({ className }), ...props }));
|
|
228
|
+
}
|
|
229
|
+
AvatarBadge.displayName = 'Avatar.Badge';
|
|
230
|
+
/** A row of overlapping avatars, that opens up when it is hovered or focused. */
|
|
231
|
+
function AvatarGroup({ max, size = 'md', shape = 'circle', label, className, style, children, ...props }) {
|
|
232
|
+
const slots = avatarVariants({ shape });
|
|
233
|
+
const context = useMemo(() => ({ size, shape }), [size, shape]);
|
|
234
|
+
const all = Children.toArray(children).filter(isValidElement);
|
|
235
|
+
const shown = max !== undefined && all.length > max ? all.slice(0, Math.max(0, max)) : all;
|
|
236
|
+
const rest = all.slice(shown.length);
|
|
237
|
+
const items = shown.map((child, index) => (_jsx(OverlappedContext.Provider, { value: index < shown.length - 1 || rest.length > 0, children: _jsx("span", { "data-slot": "avatar-group-item", className: slots.item(), children: child }) }, child.key ?? index)));
|
|
238
|
+
return (_jsx(GroupContext.Provider, { value: context, children: _jsxs("div", { role: "group", "aria-label": label, "data-slot": "avatar-group", className: slots.group({ className }), style: { ['--avatar-size']: SIZE[size], ...style }, ...props, children: [items, rest.length > 0 ? (_jsx("span", { "data-slot": "avatar-group-item", className: slots.item(), children: _jsx(AvatarGroupCount, { people: rest, shape: shape }) })) : null] }) }));
|
|
239
|
+
}
|
|
240
|
+
AvatarGroup.displayName = 'Avatar.Group';
|
|
241
|
+
/** "+3", which opens a list of the three. */
|
|
242
|
+
function AvatarGroupCount({ people, shape }) {
|
|
243
|
+
const slots = avatarVariants({ shape });
|
|
244
|
+
const names = people.map((person) => person.props.alt ?? person.props.name).filter(Boolean);
|
|
245
|
+
return (_jsx("span", { "data-slot": "avatar", className: slots.root(), children: _jsxs(Popover.Root, { children: [_jsxs(Popover.Trigger, { "data-slot": "avatar-group-count", "aria-label": `${people.length} more${names.length ? `: ${names.join(', ')}` : ''}`, className: avatarVariants({ shape }).face({ className: slots.count() }), children: ["+", people.length] }), _jsx(Popover.Portal, { children: _jsx(Popover.Positioner, { side: "bottom", align: "start", sideOffset: 8, className: "z-50", children: _jsx(Popover.Popup, { className: slots.popup(), children: _jsx(GroupContext.Provider, { value: null, children: people.map((person, index) => (_jsxs("div", { className: slots.row(), children: [_jsx(AvatarRoot, { ...person.props, size: "xs", shape: shape, children: null }), _jsx("span", { className: "min-w-0 truncate", children: person.props.name ?? person.props.alt })] }, person.key ?? index))) }) }) }) })] }) }));
|
|
246
|
+
}
|
|
247
|
+
export const Avatar = Object.assign(AvatarRoot, {
|
|
248
|
+
Badge: AvatarBadge,
|
|
249
|
+
Group: AvatarGroup,
|
|
250
|
+
});
|
|
251
|
+
export { AvatarBadge, AvatarGroup };
|
|
@@ -30,6 +30,10 @@
|
|
|
30
30
|
* a new `position` and the node goes to it, which is how nodes are driven
|
|
31
31
|
* from outside.
|
|
32
32
|
*
|
|
33
|
+
* A node drawn inside a `Flow.Group` is positioned within it, and travels
|
|
34
|
+
* with it. `confine` keeps its drag inside the group's box; `pinned` gives the
|
|
35
|
+
* drag to the group instead, so the node moves only when the group does.
|
|
36
|
+
*
|
|
33
37
|
* Edges are drawn from the boxes of the nodes they join, measured off the
|
|
34
38
|
* page. An edge therefore follows a node whatever is inside it, and redraws
|
|
35
39
|
* as either end is dragged.
|
|
@@ -124,6 +128,16 @@ export interface FlowNodeProps extends Omit<ComponentProps<'div'>, 'id'> {
|
|
|
124
128
|
position: FlowNodePosition;
|
|
125
129
|
/** Whether it can be dragged and moved with the arrow keys. */
|
|
126
130
|
draggable?: boolean;
|
|
131
|
+
/**
|
|
132
|
+
* Inside a `Flow.Group`, stop a drag at the group's edge instead of letting
|
|
133
|
+
* the node leave it. Does nothing outside a group.
|
|
134
|
+
*/
|
|
135
|
+
confine?: boolean;
|
|
136
|
+
/**
|
|
137
|
+
* Inside a `Flow.Group`, take no drag of its own: a drag that starts on it
|
|
138
|
+
* moves the group, and the node goes with it.
|
|
139
|
+
*/
|
|
140
|
+
pinned?: boolean;
|
|
127
141
|
/** Draws it as the one that has been picked. */
|
|
128
142
|
selected?: boolean;
|
|
129
143
|
/** Called when it is pressed without being dragged, or with Enter. */
|
|
@@ -134,19 +148,27 @@ export interface FlowNodeProps extends Omit<ComponentProps<'div'>, 'id'> {
|
|
|
134
148
|
moveStep?: number;
|
|
135
149
|
}
|
|
136
150
|
/** A box on the graph. Fill it with the node parts below, or with anything of your own. */
|
|
137
|
-
declare function FlowNode({ id, position, draggable, selected, onPress, onDelete, moveStep, className, style, children, ...props }: FlowNodeProps): import("react").JSX.Element;
|
|
151
|
+
declare function FlowNode({ id, position, draggable, confine, pinned, selected, onPress, onDelete, moveStep, className, style, children, ...props }: FlowNodeProps): import("react").JSX.Element;
|
|
138
152
|
declare namespace FlowNode {
|
|
139
153
|
var displayName: string;
|
|
140
154
|
}
|
|
141
|
-
export interface FlowGroupProps extends FlowNodeProps {
|
|
155
|
+
export interface FlowGroupProps extends Omit<FlowNodeProps, 'confine' | 'pinned'> {
|
|
142
156
|
/** Written in the corner of the group. */
|
|
143
157
|
label?: ReactNode;
|
|
158
|
+
/**
|
|
159
|
+
* A fixed size for the group. Give one when it holds `Flow.Node`s, which are
|
|
160
|
+
* placed inside it rather than laid out by it, so they do not size it.
|
|
161
|
+
*/
|
|
162
|
+
size?: {
|
|
163
|
+
width: number;
|
|
164
|
+
height: number;
|
|
165
|
+
};
|
|
144
166
|
}
|
|
145
167
|
/**
|
|
146
168
|
* A dashed box round a few things that belong together. It is a node like any
|
|
147
169
|
* other: it is dragged as one, and an edge can be joined to it.
|
|
148
170
|
*/
|
|
149
|
-
declare function FlowGroup({ id, position, draggable, selected: _selected, onPress, onDelete, moveStep, label, className, style, children, ...props }: FlowGroupProps): import("react").JSX.Element;
|
|
171
|
+
declare function FlowGroup({ id, position, draggable, selected: _selected, onPress, onDelete, moveStep, label, size, className, style, children, ...props }: FlowGroupProps): import("react").JSX.Element;
|
|
150
172
|
declare namespace FlowGroup {
|
|
151
173
|
var displayName: string;
|
|
152
174
|
}
|
|
@@ -211,7 +233,7 @@ declare namespace FlowHandle {
|
|
|
211
233
|
var displayName: string;
|
|
212
234
|
}
|
|
213
235
|
export interface FlowEdgeProps {
|
|
214
|
-
/** Where it leaves from: a node's `id`, or `{ node, handle }`. */
|
|
236
|
+
/** Where it leaves from: a node's `id`, `"node.handle"` for one of its handles, or `{ node, handle }`. */
|
|
215
237
|
from: FlowEndpointReference;
|
|
216
238
|
/** Where it arrives. */
|
|
217
239
|
to: FlowEndpointReference;
|
|
@@ -32,6 +32,10 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
32
32
|
* a new `position` and the node goes to it, which is how nodes are driven
|
|
33
33
|
* from outside.
|
|
34
34
|
*
|
|
35
|
+
* A node drawn inside a `Flow.Group` is positioned within it, and travels
|
|
36
|
+
* with it. `confine` keeps its drag inside the group's box; `pinned` gives the
|
|
37
|
+
* drag to the group instead, so the node moves only when the group does.
|
|
38
|
+
*
|
|
35
39
|
* Edges are drawn from the boxes of the nodes they join, measured off the
|
|
36
40
|
* page. An edge therefore follows a node whatever is inside it, and redraws
|
|
37
41
|
* as either end is dragged.
|
|
@@ -94,6 +98,8 @@ const flowVariants = tv({
|
|
|
94
98
|
});
|
|
95
99
|
const FlowContext = createContext(null);
|
|
96
100
|
const NodeContext = createContext(null);
|
|
101
|
+
/** The group a node is drawn inside, if any. */
|
|
102
|
+
const GroupContext = createContext(null);
|
|
97
103
|
function useFlow(part) {
|
|
98
104
|
const flow = useContext(FlowContext);
|
|
99
105
|
if (!flow)
|
|
@@ -111,7 +117,18 @@ function FlowRoot({ defaultViewport = { x: 0, y: 0, zoom: 1 }, minZoom = 0.3, ma
|
|
|
111
117
|
const slots = flowVariants();
|
|
112
118
|
const root = useRef(null);
|
|
113
119
|
const [viewport, setViewportState] = useState(defaultViewport);
|
|
114
|
-
const [
|
|
120
|
+
const [local, setRects] = useState({});
|
|
121
|
+
const [parents, setParents] = useState({});
|
|
122
|
+
// A node in a group is placed relative to it; everything that draws or
|
|
123
|
+
// fits reads the box where it actually is on the graph.
|
|
124
|
+
const rects = useMemo(() => {
|
|
125
|
+
const out = {};
|
|
126
|
+
for (const [id, box] of Object.entries(local)) {
|
|
127
|
+
const parent = parents[id] ? local[parents[id]] : undefined;
|
|
128
|
+
out[id] = parent ? { ...box, x: parent.x + box.x, y: parent.y + box.y } : box;
|
|
129
|
+
}
|
|
130
|
+
return out;
|
|
131
|
+
}, [local, parents]);
|
|
115
132
|
const [handles, setHandles] = useState({});
|
|
116
133
|
const [locked, setLocked] = useState(false);
|
|
117
134
|
const [size, setSize] = useState({ width: 0, height: 0 });
|
|
@@ -275,7 +292,15 @@ function FlowRoot({ defaultViewport = { x: 0, y: 0, zoom: 1 }, minZoom = 0.3, ma
|
|
|
275
292
|
window.addEventListener('pointerup', up);
|
|
276
293
|
window.addEventListener('pointercancel', up);
|
|
277
294
|
}, [toGraph]);
|
|
278
|
-
const place = useCallback((id, position) => {
|
|
295
|
+
const place = useCallback((id, position, group) => {
|
|
296
|
+
if (group !== undefined) {
|
|
297
|
+
setParents((current) => {
|
|
298
|
+
if ((current[id] ?? null) === group)
|
|
299
|
+
return current;
|
|
300
|
+
const { [id]: _was, ...rest } = current;
|
|
301
|
+
return group ? { ...rest, [id]: group } : rest;
|
|
302
|
+
});
|
|
303
|
+
}
|
|
279
304
|
setRects((current) => {
|
|
280
305
|
const before = current[id];
|
|
281
306
|
if (before && before.x === position.x && before.y === position.y)
|
|
@@ -295,6 +320,12 @@ function FlowRoot({ defaultViewport = { x: 0, y: 0, zoom: 1 }, minZoom = 0.3, ma
|
|
|
295
320
|
});
|
|
296
321
|
}, []);
|
|
297
322
|
const forget = useCallback((id) => {
|
|
323
|
+
setParents((current) => {
|
|
324
|
+
if (!(id in current))
|
|
325
|
+
return current;
|
|
326
|
+
const { [id]: _gone, ...rest } = current;
|
|
327
|
+
return rest;
|
|
328
|
+
});
|
|
298
329
|
setRects((current) => {
|
|
299
330
|
if (!(id in current))
|
|
300
331
|
return current;
|
|
@@ -311,6 +342,7 @@ function FlowRoot({ defaultViewport = { x: 0, y: 0, zoom: 1 }, minZoom = 0.3, ma
|
|
|
311
342
|
const context = useMemo(() => ({
|
|
312
343
|
viewport,
|
|
313
344
|
rects,
|
|
345
|
+
local,
|
|
314
346
|
handles,
|
|
315
347
|
locked,
|
|
316
348
|
size,
|
|
@@ -328,6 +360,7 @@ function FlowRoot({ defaultViewport = { x: 0, y: 0, zoom: 1 }, minZoom = 0.3, ma
|
|
|
328
360
|
}), [
|
|
329
361
|
viewport,
|
|
330
362
|
rects,
|
|
363
|
+
local,
|
|
331
364
|
handles,
|
|
332
365
|
locked,
|
|
333
366
|
size,
|
|
@@ -384,16 +417,28 @@ function FlowBackground({ variant = 'dots', gap = 24, size, className }) {
|
|
|
384
417
|
}
|
|
385
418
|
FlowBackground.displayName = 'Flow.Background';
|
|
386
419
|
/** What a node and a group share: a box on the graph that can be moved. */
|
|
387
|
-
function useMovable({ id, position, draggable, onPress, onDelete, moveStep, }) {
|
|
420
|
+
function useMovable({ id, position, draggable, confine, pinned, group, onPress, onDelete, moveStep, }) {
|
|
388
421
|
const flow = useFlow('Flow.Node');
|
|
389
422
|
const element = useRef(null);
|
|
390
423
|
const { place, measure, forget } = flow;
|
|
391
424
|
const step = moveStep ?? 24;
|
|
425
|
+
const movable = draggable !== false && !pinned && !flow.locked;
|
|
392
426
|
useLayoutEffect(() => {
|
|
393
|
-
place(id, position);
|
|
427
|
+
place(id, position, group);
|
|
394
428
|
// Only when the caller's position changes, not on every drawing.
|
|
395
429
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
396
|
-
}, [id, position.x, position.y, place]);
|
|
430
|
+
}, [id, position.x, position.y, group, place]);
|
|
431
|
+
/** Where a move would put it, held inside its group when it is confined. */
|
|
432
|
+
const within = (next) => {
|
|
433
|
+
const box = group && confine ? flow.local[group] : undefined;
|
|
434
|
+
if (!box || box.width === 0)
|
|
435
|
+
return next;
|
|
436
|
+
const self = flow.local[id];
|
|
437
|
+
return {
|
|
438
|
+
x: clamp(next.x, 0, Math.max(0, box.width - (self?.width ?? 0))),
|
|
439
|
+
y: clamp(next.y, 0, Math.max(0, box.height - (self?.height ?? 0))),
|
|
440
|
+
};
|
|
441
|
+
};
|
|
397
442
|
useLayoutEffect(() => {
|
|
398
443
|
const node = element.current;
|
|
399
444
|
if (!node)
|
|
@@ -405,7 +450,7 @@ function useMovable({ id, position, draggable, onPress, onDelete, moveStep, }) {
|
|
|
405
450
|
return () => observer.disconnect();
|
|
406
451
|
}, [id, measure]);
|
|
407
452
|
useEffect(() => () => forget(id), [id, forget]);
|
|
408
|
-
const at = flow.
|
|
453
|
+
const at = flow.local[id] ?? { ...position, width: 0, height: 0 };
|
|
409
454
|
const drag = useRef(null);
|
|
410
455
|
const latest = useRef(at);
|
|
411
456
|
latest.current = at;
|
|
@@ -416,6 +461,10 @@ function useMovable({ id, position, draggable, onPress, onDelete, moveStep, }) {
|
|
|
416
461
|
if (event.target.closest('[data-flow-handle], button, a, input, textarea, select, [data-no-drag]')) {
|
|
417
462
|
return;
|
|
418
463
|
}
|
|
464
|
+
// A pinned node lets the press through to the group it is in, which
|
|
465
|
+
// takes the drag.
|
|
466
|
+
if (pinned && group)
|
|
467
|
+
return;
|
|
419
468
|
// Kept from the pane, and from a group this node may be sitting in.
|
|
420
469
|
event.stopPropagation();
|
|
421
470
|
event.currentTarget.setPointerCapture(event.pointerId);
|
|
@@ -430,9 +479,9 @@ function useMovable({ id, position, draggable, onPress, onDelete, moveStep, }) {
|
|
|
430
479
|
if (!state.moved && Math.hypot(dx, dy) < 3)
|
|
431
480
|
return;
|
|
432
481
|
state.moved = true;
|
|
433
|
-
if (
|
|
482
|
+
if (!movable)
|
|
434
483
|
return;
|
|
435
|
-
place(id, { x: state.from.x + dx / flow.viewport.zoom, y: state.from.y + dy / flow.viewport.zoom });
|
|
484
|
+
place(id, within({ x: state.from.x + dx / flow.viewport.zoom, y: state.from.y + dy / flow.viewport.zoom }));
|
|
436
485
|
};
|
|
437
486
|
const onPointerUp = () => {
|
|
438
487
|
const state = drag.current;
|
|
@@ -441,7 +490,7 @@ function useMovable({ id, position, draggable, onPress, onDelete, moveStep, }) {
|
|
|
441
490
|
return;
|
|
442
491
|
if (!state.moved)
|
|
443
492
|
onPress?.();
|
|
444
|
-
else if (
|
|
493
|
+
else if (movable) {
|
|
445
494
|
flow.onNodeDragEnd?.(id, { x: latest.current.x, y: latest.current.y });
|
|
446
495
|
}
|
|
447
496
|
};
|
|
@@ -458,11 +507,11 @@ function useMovable({ id, position, draggable, onPress, onDelete, moveStep, }) {
|
|
|
458
507
|
if (by) {
|
|
459
508
|
// Kept from the canvas, which would pan on the same key.
|
|
460
509
|
event.stopPropagation();
|
|
461
|
-
if (
|
|
510
|
+
if (!movable)
|
|
462
511
|
return;
|
|
463
512
|
event.preventDefault();
|
|
464
513
|
const far = step * (event.shiftKey ? 4 : 1);
|
|
465
|
-
const next = { x: at.x + by[0] * far, y: at.y + by[1] * far };
|
|
514
|
+
const next = within({ x: at.x + by[0] * far, y: at.y + by[1] * far });
|
|
466
515
|
place(id, next);
|
|
467
516
|
flow.onNodeDragEnd?.(id, next);
|
|
468
517
|
}
|
|
@@ -492,18 +541,19 @@ function useMovable({ id, position, draggable, onPress, onDelete, moveStep, }) {
|
|
|
492
541
|
};
|
|
493
542
|
}
|
|
494
543
|
/** A box on the graph. Fill it with the node parts below, or with anything of your own. */
|
|
495
|
-
function FlowNode({ id, position, draggable = true, selected = false, onPress, onDelete, moveStep, className, style, children, ...props }) {
|
|
496
|
-
const
|
|
497
|
-
|
|
544
|
+
function FlowNode({ id, position, draggable = true, confine = false, pinned = false, selected = false, onPress, onDelete, moveStep, className, style, children, ...props }) {
|
|
545
|
+
const group = useContext(GroupContext);
|
|
546
|
+
const movable = useMovable({ id, position, draggable, confine, pinned, group, onPress, onDelete, moveStep });
|
|
547
|
+
return (_jsx(NodeContext.Provider, { value: id, children: _jsx("div", { ref: movable.element, "data-flow-node": id, "data-pinned": pinned && group ? '' : undefined, tabIndex: 0, "aria-roledescription": "Node", "aria-selected": selected || undefined, className: flowVariants({ selected }).node({ className }), style: { ...movable.style, ...style }, ...movable.handlers, ...props, children: children }) }));
|
|
498
548
|
}
|
|
499
549
|
FlowNode.displayName = 'Flow.Node';
|
|
500
550
|
/**
|
|
501
551
|
* A dashed box round a few things that belong together. It is a node like any
|
|
502
552
|
* other: it is dragged as one, and an edge can be joined to it.
|
|
503
553
|
*/
|
|
504
|
-
function FlowGroup({ id, position, draggable = true, selected: _selected, onPress, onDelete, moveStep, label, className, style, children, ...props }) {
|
|
505
|
-
const movable = useMovable({ id, position, draggable, onPress, onDelete, moveStep });
|
|
506
|
-
return (_jsx(NodeContext.Provider, { value: id, children: _jsxs("div", { ref: movable.element, "data-flow-node": id, tabIndex: 0, "aria-roledescription": "Group", className: flowVariants().group({ className }), style: { ...movable.style, ...style }, ...movable.handlers, ...props, children: [label !== undefined ? _jsx("span", { className: flowVariants().groupLabel(), children: label }) : null, children] }) }));
|
|
554
|
+
function FlowGroup({ id, position, draggable = true, selected: _selected, onPress, onDelete, moveStep, label, size, className, style, children, ...props }) {
|
|
555
|
+
const movable = useMovable({ id, position, draggable, group: null, onPress, onDelete, moveStep });
|
|
556
|
+
return (_jsx(GroupContext.Provider, { value: id, children: _jsx(NodeContext.Provider, { value: id, children: _jsxs("div", { ref: movable.element, "data-flow-node": id, tabIndex: 0, "aria-roledescription": "Group", className: flowVariants().group({ className }), style: { ...movable.style, ...(size ? { width: size.width, height: size.height } : null), ...style }, ...movable.handlers, ...props, children: [label !== undefined ? _jsx("span", { className: flowVariants().groupLabel(), children: label }) : null, children] }) }) }));
|
|
507
557
|
}
|
|
508
558
|
FlowGroup.displayName = 'Flow.Group';
|
|
509
559
|
/** A strip across the top of a node, ruled off from the rest: a step number and a state. */
|
|
@@ -566,7 +616,18 @@ function FlowHandle({ id, position = 'right', offset = 0.5, className, style, ..
|
|
|
566
616
|
}, ...props }));
|
|
567
617
|
}
|
|
568
618
|
FlowHandle.displayName = 'Flow.Handle';
|
|
569
|
-
|
|
619
|
+
/**
|
|
620
|
+
* `"node"`, `"node.handle"` or `{ node, handle }`. A string that is a node's
|
|
621
|
+
* whole id names that node, so an id with a dot in it still works.
|
|
622
|
+
*/
|
|
623
|
+
const endpoint = (reference, rects) => {
|
|
624
|
+
if (typeof reference !== 'string')
|
|
625
|
+
return reference;
|
|
626
|
+
const dot = reference.lastIndexOf('.');
|
|
627
|
+
if (dot <= 0 || reference in rects)
|
|
628
|
+
return { node: reference };
|
|
629
|
+
return { node: reference.slice(0, dot), handle: reference.slice(dot + 1) };
|
|
630
|
+
};
|
|
570
631
|
/** A line from one node to another, which follows them as they move. */
|
|
571
632
|
function FlowEdge({ from, to, variant = 'bezier', animated = false, dashed = false, color, width = 2, arrow = false, fromSide, toSide, radius = 8, gap = 20, curvature = 0.25, }) {
|
|
572
633
|
const flow = useFlow('Flow.Edge');
|
|
@@ -579,8 +640,8 @@ function FlowEdge({ from, to, variant = 'bezier', animated = false, dashed = fal
|
|
|
579
640
|
query.addEventListener('change', read);
|
|
580
641
|
return () => query.removeEventListener('change', read);
|
|
581
642
|
}, []);
|
|
582
|
-
const source = endpoint(from);
|
|
583
|
-
const target = endpoint(to);
|
|
643
|
+
const source = endpoint(from, flow.rects);
|
|
644
|
+
const target = endpoint(to, flow.rects);
|
|
584
645
|
const a = flow.rects[source.node];
|
|
585
646
|
const b = flow.rects[target.node];
|
|
586
647
|
// Nothing to draw until both ends have been measured.
|