@artooi/ag-ui-web-component 0.32.0 → 0.33.1

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.
@@ -1,3 +1,13 @@
1
+ /**
2
+ * Which edges a grip drags. An axis left out is an axis the grip does not
3
+ * move: the left-edge grip is `{ x: "left" }`, the top-right corner is
4
+ * `{ x: "right", y: "top" }`.
5
+ */
6
+ export interface ResizeGrip {
7
+ readonly x?: "left" | "right";
8
+ readonly y?: "top" | "bottom";
9
+ }
10
+
1
11
  /** Which edges the layout holds still while the panel changes size. */
2
12
  export interface ResizeAnchor {
3
13
  /** The horizontal edge that does not move. */
@@ -8,7 +18,7 @@ export interface ResizeAnchor {
8
18
 
9
19
  /**
10
20
  * What the current placement allows: both axes, width only, or nothing. Which
11
- * corner the grip sits on is separate, and is measured rather than assumed.
21
+ * edges a given grip drags is separate, and fixed when the grip is built.
12
22
  */
13
23
  export type ResizeAxis = "none" | "width" | "both";
14
24
 
@@ -18,7 +28,7 @@ export interface ResizeSize {
18
28
  readonly height?: number;
19
29
  }
20
30
 
21
- /** The panel's position on screen at the moment a drag starts. */
31
+ /** The panel's position on screen, in viewport coordinates. */
22
32
  export interface PanelRect {
23
33
  readonly left: number;
24
34
  readonly top: number;
@@ -34,23 +44,16 @@ export interface ResizeOptions {
34
44
  * would survive the host switching to a docked or full-bleed layout.
35
45
  */
36
46
  readonly axis: () => ResizeAxis;
37
- /**
38
- * Which edges the layout is holding still, measured at the moment of the drag
39
- * rather than derived from `placement`: a floating panel is pinned
40
- * bottom-right, while an embedded one goes wherever the host's CSS puts it,
41
- * so `placement` alone cannot answer the question.
42
- */
43
- readonly anchor: () => ResizeAnchor;
44
47
  /** The panel's current bounding box. */
45
48
  readonly rect: () => PanelRect;
46
- /** Apply a size (the host writes the custom properties). */
47
- readonly apply: (size: ResizeSize) => void;
49
+ /** Apply a box (the host decides what that costs in properties). */
50
+ readonly apply: (box: PanelRect) => void;
48
51
  /**
49
52
  * Called once per completed resize, for persistence: on `pointerup` for a
50
53
  * drag, and when the key comes up (or focus leaves the handle) for a key
51
54
  * press. Never per pointer move, and never per key repeat.
52
55
  */
53
- readonly commit: (size: ResizeSize) => void;
56
+ readonly commit: (box: PanelRect) => void;
54
57
  /** Accessible label. */
55
58
  readonly label: string;
56
59
  }
@@ -59,68 +62,63 @@ export interface ResizeOptions {
59
62
  const MIN_WIDTH = 280;
60
63
  const MIN_HEIGHT = 240;
61
64
 
65
+ /** Keyboard step, and the larger one Shift asks for. */
66
+ const STEP = 16;
67
+ const COARSE_STEP = 64;
68
+
62
69
  /**
63
- * A drag handle that resizes the chat panel.
70
+ * A drag handle that resizes the chat panel from one edge or corner.
64
71
  *
65
- * Two rules keep it correct, and both are easy to break invisibly:
72
+ * The rule that keeps it correct is that **the edge a grip does not drag is the
73
+ * one that stays put**. That is the whole model, and it is what lets the same
74
+ * code serve all eight grips: the left-edge grip moves the left edge and holds
75
+ * the right, the right-edge grip does the reverse, a corner does both axes.
66
76
  *
67
- * - The new size is measured from the edge that is *not* moving, never from a
68
- * delta, and that edge is measured rather than assumed. Getting it wrong is
69
- * very visible: the panel shrinks when dragged outward and travels by its
70
- * opposite corner.
71
- * - It writes the `--ag-ui-width` / `--ag-ui-height` custom properties, not
72
- * inline `width` / `height`. The placement rules set those same properties,
73
- * so an inline dimension would outrank and fight them — a sidebar would keep
74
- * its dragged width after switching to fullscreen. Writing the property
75
- * leaves placement the final say.
77
+ * Which edge the *layout* pins is a different question and is deliberately not
78
+ * asked here. It matters only to the host, which has to rewrite its own
79
+ * position when a grip drags the very edge the layout was holding still --
80
+ * dragging the pinned edge of a panel is a move as much as a resize. The
81
+ * handle reports the box it wants; what that costs in CSS properties is the
82
+ * host's problem.
76
83
  *
77
- * Axes and anchor are both read per interaction, so a runtime `placement`
78
- * change takes effect at once.
84
+ * An earlier version took the anchor and derived the direction from it, which
85
+ * is where the asymmetries lived: whether an arrow key grew or shrank the
86
+ * panel depended on which corner the single grip had been placed on. With the
87
+ * grip stated outright, an arrow simply moves the edge it names.
79
88
  */
80
- export function createResizeHandle(options: ResizeOptions): HTMLDivElement {
89
+ export function createResizeHandle(grip: ResizeGrip, options: ResizeOptions): HTMLDivElement {
81
90
  const handle = document.createElement("div");
82
- handle.className = "resize-handle";
83
- handle.setAttribute("part", "resize-handle");
91
+ handle.className = `resize-handle resize-handle--${gripName(grip)}`;
92
+ handle.setAttribute("part", `resize-handle resize-handle-${gripName(grip)}`);
93
+ // A separator with an orientation: an edge grip splits along one axis, and a
94
+ // corner has no single one to report.
84
95
  handle.setAttribute("role", "separator");
96
+ if (grip.x === undefined) {
97
+ handle.setAttribute("aria-orientation", "horizontal");
98
+ } else if (grip.y === undefined) {
99
+ handle.setAttribute("aria-orientation", "vertical");
100
+ }
85
101
  handle.setAttribute("aria-label", options.label);
86
102
  handle.tabIndex = 0;
87
103
 
88
- /** The size implied by a pointer at (x, y), given which edges are pinned. */
89
- const sizeAt = (
90
- axis: ResizeAxis,
91
- anchor: ResizeAnchor,
92
- rect: PanelRect,
93
- x: number,
94
- y: number,
95
- ): ResizeSize => {
96
- const width = anchor.x === "right" ? rect.right - x : x - rect.left;
97
- const clamped: ResizeSize = { width: Math.max(MIN_WIDTH, width) };
98
- if (axis !== "both") {
99
- return clamped;
100
- }
101
- const height = anchor.y === "bottom" ? rect.bottom - y : y - rect.top;
102
- return { ...clamped, height: Math.max(MIN_HEIGHT, height) };
103
- };
104
-
105
104
  handle.addEventListener("pointerdown", (event: PointerEvent) => {
106
105
  const axis = options.axis();
107
- if (axis === "none") {
106
+ if (axis === "none" || !movable(grip, axis)) {
108
107
  return;
109
108
  }
110
- // Captured once: the pinned edges cannot move during the drag, and reading
111
- // them live would chase the panel as it resizes.
112
- const anchor = options.anchor();
109
+ // Captured once: the edges this grip is not dragging cannot move during
110
+ // the drag, and reading them live would chase the panel as it resizes.
113
111
  const rect = options.rect();
114
112
 
115
113
  const onMove = (move: PointerEvent): void => {
116
- options.apply(sizeAt(axis, anchor, rect, move.clientX, move.clientY));
114
+ options.apply(boxAt(grip, axis, rect, move.clientX, move.clientY));
117
115
  };
118
116
 
119
117
  const onUp = (up: PointerEvent): void => {
120
118
  window.removeEventListener("pointermove", onMove);
121
119
  window.removeEventListener("pointerup", onUp);
122
120
  handle.removeAttribute("data-dragging");
123
- options.commit(sizeAt(axis, anchor, rect, up.clientX, up.clientY));
121
+ options.commit(boxAt(grip, axis, rect, up.clientX, up.clientY));
124
122
  };
125
123
 
126
124
  handle.setAttribute("data-dragging", "true");
@@ -131,49 +129,44 @@ export function createResizeHandle(options: ResizeOptions): HTMLDivElement {
131
129
  event.preventDefault();
132
130
  });
133
131
 
134
- // The size the current key gesture has applied but not yet persisted. The
132
+ // The box the current key gesture has applied but not yet persisted. The
135
133
  // pointer path can commit inline because a drag has one unambiguous end;
136
134
  // a key press does not, so the gesture's result is held here until it does.
137
- let pending: ResizeSize | null = null;
135
+ let pending: PanelRect | null = null;
138
136
 
139
137
  /** End a key gesture: persist what it applied, once. */
140
138
  const settle = (): void => {
141
139
  if (pending === null) {
142
140
  return;
143
141
  }
144
- const size = pending;
142
+ const box = pending;
145
143
  pending = null;
146
- options.commit(size);
144
+ options.commit(box);
147
145
  };
148
146
 
149
147
  // Keyboard parity: a pointer-only resize is unreachable without a mouse, and
150
- // this control has no equivalent elsewhere in the UI.
148
+ // this control has no equivalent elsewhere in the UI. An arrow moves the edge
149
+ // this grip names, in the direction it names -- so the same key grows one
150
+ // side's grip and shrinks the opposite one, which is what a pointer does too.
151
151
  handle.addEventListener("keydown", (event: KeyboardEvent) => {
152
152
  const axis = options.axis();
153
- if (axis === "none") {
153
+ if (axis === "none" || !movable(grip, axis)) {
154
154
  return;
155
155
  }
156
- const anchor = options.anchor();
156
+ const step = event.shiftKey ? COARSE_STEP : STEP;
157
157
  const rect = options.rect();
158
- const step = event.shiftKey ? 64 : 16;
159
- // An arrow moves the grip, so whether it grows or shrinks depends on which
160
- // side the grip is on — the asymmetry the pointer path also handles.
161
- const outward = anchor.x === "right" ? -1 : 1;
162
- const width = rect.right - rect.left;
163
- const height = rect.bottom - rect.top;
164
- let next: ResizeSize | null = null;
165
- if (event.key === "ArrowLeft") {
166
- next = { width: Math.max(MIN_WIDTH, width - step * outward) };
167
- } else if (event.key === "ArrowRight") {
168
- next = { width: Math.max(MIN_WIDTH, width + step * outward) };
169
- } else if (axis === "both" && (event.key === "ArrowUp" || event.key === "ArrowDown")) {
170
- const grow = event.key === (anchor.y === "bottom" ? "ArrowUp" : "ArrowDown");
171
- next = { height: Math.max(MIN_HEIGHT, height + (grow ? step : -step)) };
158
+ const delta = ARROWS[event.key];
159
+ if (delta === undefined) {
160
+ return;
172
161
  }
173
- if (next === null) {
162
+ // An arrow across this grip's fixed axis has no edge to move.
163
+ if ((delta.x !== 0 && grip.x === undefined) || (delta.y !== 0 && grip.y === undefined)) {
174
164
  return;
175
165
  }
176
166
  event.preventDefault();
167
+ const x = (grip.x === "left" ? rect.left : rect.right) + delta.x * step;
168
+ const y = (grip.y === "top" ? rect.top : rect.bottom) + delta.y * step;
169
+ const next = boxAt(grip, axis, rect, x, y);
177
170
  // Live feedback per key event, persistence only when the gesture ends:
178
171
  // `commit` promises one call per completed resize, and a held arrow key
179
172
  // repeats at the OS rate (20-30 events a second), so committing here would
@@ -191,3 +184,46 @@ export function createResizeHandle(options: ResizeOptions): HTMLDivElement {
191
184
 
192
185
  return handle;
193
186
  }
187
+
188
+ /** Which way each arrow key pushes the edge under it. */
189
+ const ARROWS: Record<string, { x: number; y: number } | undefined> = {
190
+ ArrowLeft: { x: -1, y: 0 },
191
+ ArrowRight: { x: 1, y: 0 },
192
+ ArrowUp: { x: 0, y: -1 },
193
+ ArrowDown: { x: 0, y: 1 },
194
+ };
195
+
196
+ /** The hyphenated name of a grip, for its class and part. */
197
+ export function gripName(grip: ResizeGrip): string {
198
+ return [grip.y, grip.x].filter((side) => side !== undefined).join("-");
199
+ }
200
+
201
+ /** Whether the current placement leaves this grip anything to move. */
202
+ function movable(grip: ResizeGrip, axis: ResizeAxis): boolean {
203
+ // A docked panel owns its height, so a grip that only moves a horizontal
204
+ // edge has nothing to do and must not pretend otherwise.
205
+ return axis === "both" || grip.x !== undefined;
206
+ }
207
+
208
+ /**
209
+ * The box a pointer at (x, y) implies for this grip.
210
+ *
211
+ * Each axis is clamped by pushing the *dragged* edge back to the minimum,
212
+ * never by moving the edge that is supposed to be standing still: clamping the
213
+ * wrong one is how a panel dragged past its minimum starts travelling.
214
+ */
215
+ function boxAt(
216
+ grip: ResizeGrip,
217
+ axis: ResizeAxis,
218
+ rect: PanelRect,
219
+ x: number,
220
+ y: number,
221
+ ): PanelRect {
222
+ const left = grip.x === "left" ? Math.min(x, rect.right - MIN_WIDTH) : rect.left;
223
+ const right = grip.x === "right" ? Math.max(x, rect.left + MIN_WIDTH) : rect.right;
224
+ // A placement that owns its height leaves the vertical edges where they are.
225
+ const vertical = axis === "both";
226
+ const top = vertical && grip.y === "top" ? Math.min(y, rect.bottom - MIN_HEIGHT) : rect.top;
227
+ const bottom = vertical && grip.y === "bottom" ? Math.max(y, rect.top + MIN_HEIGHT) : rect.bottom;
228
+ return { left, top, right, bottom };
229
+ }