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.
@@ -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 [rects, setRects] = useState({});
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.rects[id] ?? { ...position, width: 0, height: 0 };
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 (draggable === false || flow.locked)
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 (draggable !== false && !flow.locked) {
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 (draggable === false || flow.locked)
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 movable = useMovable({ id, position, draggable, onPress, onDelete, moveStep });
497
- return (_jsx(NodeContext.Provider, { value: id, children: _jsx("div", { ref: movable.element, "data-flow-node": id, tabIndex: 0, "aria-roledescription": "Node", "aria-selected": selected || undefined, className: flowVariants({ selected }).node({ className }), style: { ...movable.style, ...style }, ...movable.handlers, ...props, children: children }) }));
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
- const endpoint = (reference) => typeof reference === 'string' ? { node: reference } : reference;
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.