@multiplatform.one/forms 7.20.0 → 7.22.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/cjs/FloatingPanel/index.cjs +85 -29
- package/dist/cjs/FloatingPanel/index.native.js +108 -11
- package/dist/cjs/FloatingPanel/index.native.js.map +1 -1
- package/dist/cjs/FloatingPanel/pointAnchor.cjs +88 -0
- package/dist/cjs/FloatingPanel/pointAnchor.native.js +98 -0
- package/dist/cjs/FloatingPanel/pointAnchor.native.js.map +1 -0
- package/dist/cjs/FloatingPanel/useFloatingPanel.cjs +4 -3
- package/dist/cjs/FloatingPanel/useFloatingPanel.native.js +4 -3
- package/dist/cjs/FloatingPanel/useFloatingPanel.native.js.map +1 -1
- package/dist/cjs/fields/Combobox/index.cjs +39 -28
- package/dist/cjs/fields/Combobox/index.native.js +54 -30
- package/dist/cjs/fields/Combobox/index.native.js.map +1 -1
- package/dist/cjs/fields/Switch/footprint.cjs +1 -1
- package/dist/cjs/fields/Switch/footprint.native.js +1 -1
- package/dist/esm/FloatingPanel/index.mjs +86 -30
- package/dist/esm/FloatingPanel/index.mjs.map +1 -1
- package/dist/esm/FloatingPanel/index.native.js +107 -10
- package/dist/esm/FloatingPanel/index.native.js.map +1 -1
- package/dist/esm/FloatingPanel/pointAnchor.mjs +59 -0
- package/dist/esm/FloatingPanel/pointAnchor.mjs.map +1 -0
- package/dist/esm/FloatingPanel/pointAnchor.native.js +66 -0
- package/dist/esm/FloatingPanel/pointAnchor.native.js.map +1 -0
- package/dist/esm/FloatingPanel/useFloatingPanel.mjs +4 -3
- package/dist/esm/FloatingPanel/useFloatingPanel.mjs.map +1 -1
- package/dist/esm/FloatingPanel/useFloatingPanel.native.js +4 -3
- package/dist/esm/FloatingPanel/useFloatingPanel.native.js.map +1 -1
- package/dist/esm/fields/Combobox/index.mjs +39 -28
- package/dist/esm/fields/Combobox/index.mjs.map +1 -1
- package/dist/esm/fields/Combobox/index.native.js +54 -30
- package/dist/esm/fields/Combobox/index.native.js.map +1 -1
- package/dist/esm/fields/Switch/footprint.mjs +1 -1
- package/dist/esm/fields/Switch/footprint.native.js +1 -1
- package/dist/jsx/FloatingPanel/index.mjs +86 -30
- package/dist/jsx/FloatingPanel/index.mjs.map +1 -1
- package/dist/jsx/FloatingPanel/index.native.js +108 -11
- package/dist/jsx/FloatingPanel/index.native.js.map +1 -1
- package/dist/jsx/FloatingPanel/pointAnchor.mjs +59 -0
- package/dist/jsx/FloatingPanel/pointAnchor.mjs.map +1 -0
- package/dist/jsx/FloatingPanel/pointAnchor.native.js +98 -0
- package/dist/jsx/FloatingPanel/pointAnchor.native.js.map +1 -0
- package/dist/jsx/FloatingPanel/useFloatingPanel.mjs +4 -3
- package/dist/jsx/FloatingPanel/useFloatingPanel.mjs.map +1 -1
- package/dist/jsx/FloatingPanel/useFloatingPanel.native.js +4 -3
- package/dist/jsx/FloatingPanel/useFloatingPanel.native.js.map +1 -1
- package/dist/jsx/fields/Combobox/index.mjs +39 -28
- package/dist/jsx/fields/Combobox/index.mjs.map +1 -1
- package/dist/jsx/fields/Combobox/index.native.js +54 -30
- package/dist/jsx/fields/Combobox/index.native.js.map +1 -1
- package/dist/jsx/fields/Switch/footprint.mjs +1 -1
- package/dist/jsx/fields/Switch/footprint.native.js +1 -1
- package/package.json +8 -8
- package/src/FloatingPanel/FloatingPanel.pointMenu.native.spec.tsx +126 -0
- package/src/FloatingPanel/index.native.tsx +199 -81
- package/src/FloatingPanel/index.tsx +130 -42
- package/src/FloatingPanel/pointAnchor.spec.ts +141 -0
- package/src/FloatingPanel/pointAnchor.ts +122 -0
- package/src/FloatingPanel/useFloatingPanel.ts +9 -2
- package/src/fields/Combobox/Combobox.spec.tsx +158 -0
- package/src/fields/Combobox/index.tsx +57 -28
- package/src/fields/Switch/Switch.footprint.native.spec.tsx +14 -5
- package/src/fields/Switch/footprint.ts +3 -3
- package/types/FloatingPanel/index.d.ts +4 -1
- package/types/FloatingPanel/index.d.ts.map +1 -1
- package/types/FloatingPanel/index.native.d.ts +5 -0
- package/types/FloatingPanel/index.native.d.ts.map +1 -1
- package/types/FloatingPanel/pointAnchor.d.ts +66 -0
- package/types/FloatingPanel/pointAnchor.d.ts.map +1 -0
- package/types/FloatingPanel/useFloatingPanel.d.ts +7 -1
- package/types/FloatingPanel/useFloatingPanel.d.ts.map +1 -1
- package/types/fields/Combobox/index.d.ts +5 -1
- package/types/fields/Combobox/index.d.ts.map +1 -1
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { OVERLAY_ANCHOR_GAP } from "@multiplatform.one/theme";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import {
|
|
4
|
+
contextMenuPoint,
|
|
5
|
+
placeAtPoint,
|
|
6
|
+
pointOpensUp,
|
|
7
|
+
pointPanelLeft,
|
|
8
|
+
type PanelBounds,
|
|
9
|
+
} from "./pointAnchor";
|
|
10
|
+
|
|
11
|
+
const bounds: PanelBounds = { top: 10, left: 10, right: 1014, bottom: 758 };
|
|
12
|
+
const gap = OVERLAY_ANCHOR_GAP;
|
|
13
|
+
|
|
14
|
+
describe("pointPanelLeft", () => {
|
|
15
|
+
it("puts the panel's start edge on the point when it fits", () => {
|
|
16
|
+
expect(pointPanelLeft({ x: 300, y: 0 }, 200, bounds)).toEqual({ left: 300, alignEnd: false });
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("flips to open toward the start when the end side would overflow", () => {
|
|
20
|
+
expect(pointPanelLeft({ x: 900, y: 0 }, 200, bounds)).toEqual({ left: 700, alignEnd: true });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("clamps into the bounds when neither side fits", () => {
|
|
24
|
+
const narrow: PanelBounds = { top: 10, left: 10, right: 310, bottom: 758 };
|
|
25
|
+
expect(pointPanelLeft({ x: 200, y: 0 }, 250, narrow)).toEqual({ left: 60, alignEnd: false });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("pins to the start bound when the panel is wider than the bounds", () => {
|
|
29
|
+
const narrow: PanelBounds = { top: 10, left: 10, right: 110, bottom: 758 };
|
|
30
|
+
expect(pointPanelLeft({ x: 50, y: 0 }, 400, narrow).left).toBe(10);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("opens toward the left from the point in RTL and flips right at the left edge", () => {
|
|
34
|
+
expect(pointPanelLeft({ x: 600, y: 0 }, 200, bounds, true)).toEqual({
|
|
35
|
+
left: 400,
|
|
36
|
+
alignEnd: false,
|
|
37
|
+
});
|
|
38
|
+
expect(pointPanelLeft({ x: 100, y: 0 }, 200, bounds, true)).toEqual({
|
|
39
|
+
left: 100,
|
|
40
|
+
alignEnd: true,
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("pointOpensUp", () => {
|
|
46
|
+
it("opens down whenever the panel fits below the point", () => {
|
|
47
|
+
expect(pointOpensUp({ x: 0, y: 600 }, 150, bounds, gap)).toBe(false);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("flips up when only the space above fits", () => {
|
|
51
|
+
expect(pointOpensUp({ x: 0, y: 700 }, 150, bounds, gap)).toBe(true);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("takes the roomier side when neither fits", () => {
|
|
55
|
+
expect(pointOpensUp({ x: 0, y: 300 }, 700, bounds, gap)).toBe(false);
|
|
56
|
+
expect(pointOpensUp({ x: 0, y: 500 }, 700, bounds, gap)).toBe(true);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe("placeAtPoint", () => {
|
|
61
|
+
it("sits OVERLAY_ANCHOR_GAP below the point, start edge on the point", () => {
|
|
62
|
+
expect(
|
|
63
|
+
placeAtPoint({ point: { x: 300, y: 200 }, width: 180, height: 120, bounds, gap }),
|
|
64
|
+
).toEqual({
|
|
65
|
+
left: 300,
|
|
66
|
+
top: 200 + gap,
|
|
67
|
+
maxHeight: 758 - (200 + gap),
|
|
68
|
+
dropup: false,
|
|
69
|
+
alignEnd: false,
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("flips both ways in the bottom-right corner, bottom edge gap above the point", () => {
|
|
74
|
+
const placement = placeAtPoint({
|
|
75
|
+
point: { x: 1000, y: 740 },
|
|
76
|
+
width: 180,
|
|
77
|
+
height: 120,
|
|
78
|
+
bounds,
|
|
79
|
+
gap,
|
|
80
|
+
});
|
|
81
|
+
expect(placement.left).toBe(1000 - 180);
|
|
82
|
+
expect(placement.top).toBe(740 - gap - 120);
|
|
83
|
+
expect(placement.dropup).toBe(true);
|
|
84
|
+
expect(placement.alignEnd).toBe(true);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("caps the height at the chosen side's room, so a tall menu scrolls inside the bounds", () => {
|
|
88
|
+
const placement = placeAtPoint({
|
|
89
|
+
point: { x: 300, y: 500 },
|
|
90
|
+
width: 180,
|
|
91
|
+
height: 900,
|
|
92
|
+
bounds,
|
|
93
|
+
gap,
|
|
94
|
+
});
|
|
95
|
+
expect(placement.dropup).toBe(true);
|
|
96
|
+
expect(placement.maxHeight).toBe(500 - gap - 10);
|
|
97
|
+
expect(placement.top).toBe(10);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe("contextMenuPoint", () => {
|
|
102
|
+
const reference = {
|
|
103
|
+
getBoundingClientRect: () => ({ left: 40, right: 240, top: 100, bottom: 144 }),
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
it("takes the pointer's viewport coordinates from a mouse right-click", () => {
|
|
107
|
+
expect(
|
|
108
|
+
contextMenuPoint({ clientX: 120, clientY: 130, button: 2, pointerType: "mouse" }, reference),
|
|
109
|
+
).toEqual({ x: 120, y: 130 });
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("takes the press point from a touch long-press", () => {
|
|
113
|
+
expect(
|
|
114
|
+
contextMenuPoint({ clientX: 90, clientY: 110, button: 0, pointerType: "touch" }, reference),
|
|
115
|
+
).toEqual({ x: 90, y: 110 });
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("takes a right-click's coordinates when the event is a plain MouseEvent", () => {
|
|
119
|
+
expect(contextMenuPoint({ clientX: 150, clientY: 120, button: 2 }, reference)).toEqual({
|
|
120
|
+
x: 150,
|
|
121
|
+
y: 120,
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("anchors a keyboard open (Shift+F10, the Menu key) to the target's bottom start corner", () => {
|
|
126
|
+
expect(
|
|
127
|
+
contextMenuPoint({ clientX: 0, clientY: 0, button: 0, pointerType: "" }, reference),
|
|
128
|
+
).toEqual({ x: 40, y: 144 });
|
|
129
|
+
expect(contextMenuPoint({ clientX: 0, clientY: 0, button: 0 }, reference)).toEqual({
|
|
130
|
+
x: 40,
|
|
131
|
+
y: 144,
|
|
132
|
+
});
|
|
133
|
+
expect(
|
|
134
|
+
contextMenuPoint({ clientX: 0, clientY: 0, button: 0, pointerType: "" }, reference, true),
|
|
135
|
+
).toEqual({ x: 240, y: 144 });
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("has no point without a pointer or a reference", () => {
|
|
139
|
+
expect(contextMenuPoint({ clientX: 0, clientY: 0, button: 0 }, null)).toBeNull();
|
|
140
|
+
});
|
|
141
|
+
});
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { OVERLAY_ANCHOR_GAP } from "@multiplatform.one/theme";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A panel opened at a POINT rather than from a trigger's box: the ContextMenu
|
|
5
|
+
* gesture (MPO-21 G5). layoutTokens.ts files a pointer-positioned context menu
|
|
6
|
+
* as a FREE overlay: content-sized, `OVERLAY_ANCHOR_GAP` from its anchor,
|
|
7
|
+
* flipping at the viewport edges instead of overflowing. The anchor here is a
|
|
8
|
+
* zero-size box at the point, so "start-aligned to the anchor" puts the
|
|
9
|
+
* panel's start edge on the pointer's x.
|
|
10
|
+
*/
|
|
11
|
+
export interface PanelPoint {
|
|
12
|
+
x: number;
|
|
13
|
+
y: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** The box a panel must stay inside, already inset by padding and safe area. */
|
|
17
|
+
export interface PanelBounds {
|
|
18
|
+
top: number;
|
|
19
|
+
right: number;
|
|
20
|
+
bottom: number;
|
|
21
|
+
left: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function pointPanelLeft(
|
|
25
|
+
point: PanelPoint,
|
|
26
|
+
width: number,
|
|
27
|
+
bounds: PanelBounds,
|
|
28
|
+
rtl = false,
|
|
29
|
+
): { left: number; alignEnd: boolean } {
|
|
30
|
+
const towardEnd = rtl ? point.x - width : point.x;
|
|
31
|
+
const towardStart = rtl ? point.x : point.x - width;
|
|
32
|
+
const fits = (left: number) => left >= bounds.left && left + width <= bounds.right;
|
|
33
|
+
const alignEnd = !fits(towardEnd) && fits(towardStart);
|
|
34
|
+
const raw = alignEnd ? towardStart : towardEnd;
|
|
35
|
+
return { left: Math.max(bounds.left, Math.min(raw, bounds.right - width)), alignEnd };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Below when it fits, above when only that fits, else the roomier side. */
|
|
39
|
+
export function pointOpensUp(
|
|
40
|
+
point: PanelPoint,
|
|
41
|
+
height: number,
|
|
42
|
+
bounds: PanelBounds,
|
|
43
|
+
gap: number = OVERLAY_ANCHOR_GAP,
|
|
44
|
+
): boolean {
|
|
45
|
+
const below = bounds.bottom - (point.y + gap);
|
|
46
|
+
const above = point.y - gap - bounds.top;
|
|
47
|
+
if (height <= below) return false;
|
|
48
|
+
if (height <= above) return true;
|
|
49
|
+
return above > below;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface PointPlacement {
|
|
53
|
+
left: number;
|
|
54
|
+
top: number;
|
|
55
|
+
maxHeight: number;
|
|
56
|
+
dropup: boolean;
|
|
57
|
+
alignEnd: boolean;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function placeAtPoint({
|
|
61
|
+
point,
|
|
62
|
+
width,
|
|
63
|
+
height,
|
|
64
|
+
bounds,
|
|
65
|
+
gap = OVERLAY_ANCHOR_GAP,
|
|
66
|
+
rtl = false,
|
|
67
|
+
}: {
|
|
68
|
+
point: PanelPoint;
|
|
69
|
+
width: number;
|
|
70
|
+
height: number;
|
|
71
|
+
bounds: PanelBounds;
|
|
72
|
+
gap?: number;
|
|
73
|
+
rtl?: boolean;
|
|
74
|
+
}): PointPlacement {
|
|
75
|
+
const { left, alignEnd } = pointPanelLeft(point, width, bounds, rtl);
|
|
76
|
+
const dropup = pointOpensUp(point, height, bounds, gap);
|
|
77
|
+
const maxHeight = Math.max(
|
|
78
|
+
0,
|
|
79
|
+
dropup ? point.y - gap - bounds.top : bounds.bottom - (point.y + gap),
|
|
80
|
+
);
|
|
81
|
+
const top = dropup ? point.y - gap - Math.min(height, maxHeight) : point.y + gap;
|
|
82
|
+
return { left, top, maxHeight, dropup, alignEnd };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface ContextMenuEventLike {
|
|
86
|
+
clientX: number;
|
|
87
|
+
clientY: number;
|
|
88
|
+
button?: number;
|
|
89
|
+
pointerType?: string;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Where a `contextmenu` event asks the menu to open. A mouse right-click or a
|
|
94
|
+
* touch long-press carries the pointer's viewport coordinates. A keyboard open
|
|
95
|
+
* (Shift+F10, the Menu key) has no pointer: Chromium sends a PointerEvent with
|
|
96
|
+
* an empty `pointerType` aimed at the focused element's centre
|
|
97
|
+
* (`EventHandler::ShowNonLocatedContextMenu`). It opens from the target's
|
|
98
|
+
* bottom start corner. A plain MouseEvent counts as a pointer only when it is
|
|
99
|
+
* a right-click or carries coordinates.
|
|
100
|
+
*/
|
|
101
|
+
export function contextMenuPoint(
|
|
102
|
+
event: ContextMenuEventLike,
|
|
103
|
+
reference: PointReference | null,
|
|
104
|
+
rtl = false,
|
|
105
|
+
): PanelPoint | null {
|
|
106
|
+
const fromPointer =
|
|
107
|
+
event.pointerType !== undefined
|
|
108
|
+
? event.pointerType !== ""
|
|
109
|
+
: event.button === 2 || event.clientX !== 0 || event.clientY !== 0;
|
|
110
|
+
if (fromPointer) return { x: event.clientX, y: event.clientY };
|
|
111
|
+
return reference ? referenceCornerPoint(reference, rtl) : null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface PointReference {
|
|
115
|
+
getBoundingClientRect(): { left: number; right: number; bottom: number };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** The target's bottom start corner: where a menu with no pointer opens. */
|
|
119
|
+
export function referenceCornerPoint(reference: PointReference, rtl = false): PanelPoint {
|
|
120
|
+
const rect = reference.getBoundingClientRect();
|
|
121
|
+
return { x: rtl ? rect.right : rect.left, y: rect.bottom };
|
|
122
|
+
}
|
|
@@ -104,6 +104,12 @@ export interface UseFloatingPanelOptions {
|
|
|
104
104
|
* at the compact space step.
|
|
105
105
|
*/
|
|
106
106
|
compact?: boolean;
|
|
107
|
+
/**
|
|
108
|
+
* The panel opens at a point, not from the trigger's box (ContextMenu). It
|
|
109
|
+
* shares no corner with the trigger, so its radius stays the CONTAINER-CAP
|
|
110
|
+
* value instead of dropping to the trigger's drawn arc.
|
|
111
|
+
*/
|
|
112
|
+
freeOverlay?: boolean;
|
|
107
113
|
}
|
|
108
114
|
|
|
109
115
|
/** One place that resolves the deprecated boolean onto the three modes, so
|
|
@@ -225,6 +231,7 @@ export function useFloatingPanel({
|
|
|
225
231
|
widthMode: widthModeProp,
|
|
226
232
|
fitContent,
|
|
227
233
|
compact,
|
|
234
|
+
freeOverlay = false,
|
|
228
235
|
}: UseFloatingPanelOptions): UseFloatingPanelReturn {
|
|
229
236
|
const widthMode = resolvePanelWidthMode(widthModeProp, fitContent);
|
|
230
237
|
const { knobProps } = useResolvedKnobs({ compact });
|
|
@@ -295,14 +302,14 @@ export function useFloatingPanel({
|
|
|
295
302
|
const setupForOpen = useCallback(() => {
|
|
296
303
|
computePlacement();
|
|
297
304
|
if (canUseDom && triggerRef.current) {
|
|
298
|
-
setMeasuredRadius(measureDrawnRadius(triggerRef.current));
|
|
305
|
+
setMeasuredRadius(freeOverlay ? null : measureDrawnRadius(triggerRef.current));
|
|
299
306
|
}
|
|
300
307
|
onBeforeOpenRef.current?.();
|
|
301
308
|
if (closingTimerRef.current) clearTimeout(closingTimerRef.current);
|
|
302
309
|
floatingStyleRef.current = {};
|
|
303
310
|
initializingRef.current = true;
|
|
304
311
|
setMounted(true);
|
|
305
|
-
}, [computePlacement]);
|
|
312
|
+
}, [computePlacement, freeOverlay]);
|
|
306
313
|
|
|
307
314
|
// ── Lifecycle effect ──────────────────────────────────────
|
|
308
315
|
|
|
@@ -227,6 +227,164 @@ describe("Combobox", () => {
|
|
|
227
227
|
});
|
|
228
228
|
});
|
|
229
229
|
|
|
230
|
+
describe("creatable", () => {
|
|
231
|
+
const initialWidth = window.innerWidth;
|
|
232
|
+
afterEach(() => {
|
|
233
|
+
window.innerWidth = initialWidth;
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
const toTag = (next: string) => ({ value: next.toLowerCase(), label: next });
|
|
237
|
+
|
|
238
|
+
async function openAndType(text: string) {
|
|
239
|
+
const trigger = document.body.querySelector<HTMLElement>('[role="combobox"]');
|
|
240
|
+
expect(trigger).toBeTruthy();
|
|
241
|
+
await act(async () => fireEvent.keyDown(trigger!, { key: "Enter" }));
|
|
242
|
+
const search = document.body.querySelector("input");
|
|
243
|
+
expect(search).toBeTruthy();
|
|
244
|
+
await act(async () => fireEvent.change(search!, { target: { value: text } }));
|
|
245
|
+
return search!;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function triggerText() {
|
|
249
|
+
return document.body.querySelector('[data-testid="combobox-trigger"]')?.textContent;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function createRow(text: string) {
|
|
253
|
+
return [...document.body.querySelectorAll('[role="option"]')].find(
|
|
254
|
+
(el) => el.textContent?.trim() === `Create "${text}"`,
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
for (const [surface, width] of [
|
|
259
|
+
["panel", 1200],
|
|
260
|
+
["sheet", 500],
|
|
261
|
+
] as const) {
|
|
262
|
+
it(`${surface}: a created option shows in the trigger of an uncontrolled combobox`, async () => {
|
|
263
|
+
window.innerWidth = width;
|
|
264
|
+
const onCreate = vi.fn(toTag);
|
|
265
|
+
const onChange = vi.fn();
|
|
266
|
+
renderWithProviders(
|
|
267
|
+
<Combobox
|
|
268
|
+
label="Fruit"
|
|
269
|
+
options={options}
|
|
270
|
+
placeholder="Pick a fruit"
|
|
271
|
+
creatable
|
|
272
|
+
onCreate={onCreate}
|
|
273
|
+
onChange={onChange}
|
|
274
|
+
/>,
|
|
275
|
+
);
|
|
276
|
+
await openAndType("Durian");
|
|
277
|
+
const row = createRow("Durian");
|
|
278
|
+
expect(row).toBeTruthy();
|
|
279
|
+
await act(async () => fireEvent.click(row!));
|
|
280
|
+
|
|
281
|
+
expect(onCreate).toHaveBeenCalledWith("Durian");
|
|
282
|
+
expect(onChange).toHaveBeenCalledWith("durian");
|
|
283
|
+
expect(triggerText()).toContain("Durian");
|
|
284
|
+
expect(triggerText()).not.toContain("Pick a fruit");
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it(`${surface}: a created option is listed and selected, and is not offered again`, async () => {
|
|
288
|
+
window.innerWidth = width;
|
|
289
|
+
const onCreate = vi.fn(toTag);
|
|
290
|
+
renderWithProviders(
|
|
291
|
+
<Combobox label="Fruit" options={options} creatable onCreate={onCreate} />,
|
|
292
|
+
);
|
|
293
|
+
const search = await openAndType("Durian");
|
|
294
|
+
await act(async () => fireEvent.keyDown(search, { key: "Enter" }));
|
|
295
|
+
expect(onCreate).toHaveBeenCalledTimes(1);
|
|
296
|
+
|
|
297
|
+
await openAndType("durian");
|
|
298
|
+
expect(createRow("durian")).toBeUndefined();
|
|
299
|
+
const listed = [...document.body.querySelectorAll('[role="option"]')].find(
|
|
300
|
+
(el) => el.textContent?.trim() === "Durian",
|
|
301
|
+
);
|
|
302
|
+
expect(listed?.getAttribute("aria-selected")).toBe("true");
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
it("multiple: a created option's chip shows its label, not its value", async () => {
|
|
307
|
+
window.innerWidth = 1200;
|
|
308
|
+
const onChange = vi.fn();
|
|
309
|
+
renderWithProviders(
|
|
310
|
+
<Combobox
|
|
311
|
+
label="Fruits"
|
|
312
|
+
options={options}
|
|
313
|
+
multiple
|
|
314
|
+
creatable
|
|
315
|
+
onCreate={toTag}
|
|
316
|
+
onChange={onChange}
|
|
317
|
+
/>,
|
|
318
|
+
);
|
|
319
|
+
const search = await openAndType("Durian");
|
|
320
|
+
await act(async () => fireEvent.keyDown(search, { key: "Enter" }));
|
|
321
|
+
expect(onChange).toHaveBeenCalledWith(["durian"]);
|
|
322
|
+
expect(triggerText()).toContain("Durian");
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it("a create that the host handles itself still closes a single-value panel", async () => {
|
|
326
|
+
window.innerWidth = 1200;
|
|
327
|
+
const onCreate = vi.fn(() => undefined);
|
|
328
|
+
renderWithProviders(
|
|
329
|
+
<Combobox label="Customer" options={options} creatable onCreate={onCreate} />,
|
|
330
|
+
);
|
|
331
|
+
const search = await openAndType("Acme");
|
|
332
|
+
await act(async () => fireEvent.keyDown(search, { key: "Enter" }));
|
|
333
|
+
expect(onCreate).toHaveBeenCalledWith("Acme");
|
|
334
|
+
expect(document.body.querySelector('[role="combobox"]')?.getAttribute("aria-expanded")).toBe(
|
|
335
|
+
"false",
|
|
336
|
+
);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
for (const [surface, width] of [
|
|
340
|
+
["panel", 1200],
|
|
341
|
+
["sheet", 500],
|
|
342
|
+
] as const) {
|
|
343
|
+
it(`${surface}: a space in the typed text is typed, so a two-word option can be created`, async () => {
|
|
344
|
+
window.innerWidth = width;
|
|
345
|
+
const onCreate = vi.fn(toTag);
|
|
346
|
+
renderWithProviders(
|
|
347
|
+
<Combobox label="Author" options={options} creatable onCreate={onCreate} />,
|
|
348
|
+
);
|
|
349
|
+
const search = await openAndType("Octavia");
|
|
350
|
+
let typed = false;
|
|
351
|
+
await act(async () => {
|
|
352
|
+
typed = fireEvent.keyDown(search, { key: " ", code: "Space" });
|
|
353
|
+
});
|
|
354
|
+
expect(typed).toBe(true);
|
|
355
|
+
expect(onCreate).not.toHaveBeenCalled();
|
|
356
|
+
expect(
|
|
357
|
+
document.body.querySelector('[role="combobox"]')?.getAttribute("aria-expanded"),
|
|
358
|
+
).toBe("true");
|
|
359
|
+
|
|
360
|
+
await act(async () => fireEvent.change(search, { target: { value: "Octavia Butler" } }));
|
|
361
|
+
await act(async () => fireEvent.keyDown(search, { key: "Enter" }));
|
|
362
|
+
expect(onCreate).toHaveBeenCalledWith("Octavia Butler");
|
|
363
|
+
expect(triggerText()).toContain("Octavia Butler");
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
describe("search typing", () => {
|
|
369
|
+
it("a space after search text is typed, not taken as a pick", async () => {
|
|
370
|
+
window.innerWidth = 1200;
|
|
371
|
+
const onChange = vi.fn();
|
|
372
|
+
const result = renderWithProviders(
|
|
373
|
+
<Combobox label="Fruit" options={options} open onChange={onChange} />,
|
|
374
|
+
);
|
|
375
|
+
await act(async () => {});
|
|
376
|
+
const search = result.baseElement.querySelector("input");
|
|
377
|
+
expect(search).toBeTruthy();
|
|
378
|
+
await act(async () => fireEvent.change(search!, { target: { value: "Ban" } }));
|
|
379
|
+
let typed = false;
|
|
380
|
+
await act(async () => {
|
|
381
|
+
typed = fireEvent.keyDown(search!, { key: " ", code: "Space" });
|
|
382
|
+
});
|
|
383
|
+
expect(typed).toBe(true);
|
|
384
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
385
|
+
});
|
|
386
|
+
});
|
|
387
|
+
|
|
230
388
|
describe("LC-71 / APG combobox", () => {
|
|
231
389
|
it("points aria-activedescendant at the active option id", async () => {
|
|
232
390
|
const result = renderWithProviders(
|
|
@@ -225,7 +225,11 @@ export interface ComboboxProps {
|
|
|
225
225
|
compact?: boolean;
|
|
226
226
|
/** Allow creating new options when search doesn't match existing options */
|
|
227
227
|
creatable?: boolean;
|
|
228
|
-
/**
|
|
228
|
+
/**
|
|
229
|
+
* Called with the typed text. Return an option to select it; the Combobox
|
|
230
|
+
* keeps it listed until `options` carries it. Return nothing to handle
|
|
231
|
+
* creation yourself (a single-value panel still closes).
|
|
232
|
+
*/
|
|
229
233
|
onCreate?: (value: string) => ComboboxOption | void;
|
|
230
234
|
/** Label template for the create option. Use {value} as placeholder. Default: 'Create "{value}"' */
|
|
231
235
|
createLabel?: string;
|
|
@@ -596,19 +600,20 @@ function FloatingComboboxCore({
|
|
|
596
600
|
}, [creatable, filter, options]);
|
|
597
601
|
|
|
598
602
|
const handleCreate = useCallback(() => {
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
setOpen(false);
|
|
608
|
-
}
|
|
603
|
+
const typed = filter.trim();
|
|
604
|
+
if (!onCreate || !typed) return;
|
|
605
|
+
const newOption = onCreate(typed);
|
|
606
|
+
if (newOption && multiple) {
|
|
607
|
+
const current = Array.isArray(value) ? value : value ? [value] : [];
|
|
608
|
+
if (!current.includes(newOption.value)) onValueChange?.([...current, newOption.value]);
|
|
609
|
+
} else if (newOption) {
|
|
610
|
+
onValueChange?.(newOption.value);
|
|
609
611
|
}
|
|
612
|
+
// A void onCreate hands creation to the host (a dialog, a server round
|
|
613
|
+
// trip); a single-value panel left open would sit over that UI.
|
|
614
|
+
if (!multiple) setOpen(false);
|
|
610
615
|
setInternalFilter("");
|
|
611
|
-
}, [onCreate, filter, multiple, value, onValueChange]);
|
|
616
|
+
}, [onCreate, filter, multiple, value, onValueChange, setOpen]);
|
|
612
617
|
|
|
613
618
|
// Multi-select: selected at front ONLY when first opening. Never reorder when searching or selecting.
|
|
614
619
|
const initialOrderRef = useRef<ComboboxOption[] | null>(null);
|
|
@@ -1285,6 +1290,9 @@ function FloatingComboboxCore({
|
|
|
1285
1290
|
lastKeyboardNavTimeRef.current = Date.now();
|
|
1286
1291
|
setActiveIndex(Math.max(0, totalNavigableItems - 1));
|
|
1287
1292
|
} else if (event.key === "Enter" || event.key === " ") {
|
|
1293
|
+
// Space picks only from an empty search; after text it is part of
|
|
1294
|
+
// the query (and of a value being created), as it is in the sheet.
|
|
1295
|
+
if (event.key === " " && filter.trim()) return;
|
|
1288
1296
|
event.preventDefault();
|
|
1289
1297
|
if (activeIndex === createOptionIndex && showCreateOption) {
|
|
1290
1298
|
handleCreate();
|
|
@@ -1312,6 +1320,7 @@ function FloatingComboboxCore({
|
|
|
1312
1320
|
createOptionIndex,
|
|
1313
1321
|
showCreateOption,
|
|
1314
1322
|
multiple,
|
|
1323
|
+
filter,
|
|
1315
1324
|
setKeyboardNav,
|
|
1316
1325
|
],
|
|
1317
1326
|
);
|
|
@@ -1909,19 +1918,20 @@ function NativeComboboxCore({
|
|
|
1909
1918
|
}, [creatable, filter, options]);
|
|
1910
1919
|
|
|
1911
1920
|
const handleCreate = useCallback(() => {
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
setOpen(false);
|
|
1921
|
-
}
|
|
1921
|
+
const typed = filter.trim();
|
|
1922
|
+
if (!onCreate || !typed) return;
|
|
1923
|
+
const newOption = onCreate(typed);
|
|
1924
|
+
if (newOption && multiple) {
|
|
1925
|
+
const current = Array.isArray(value) ? value : value ? [value] : [];
|
|
1926
|
+
if (!current.includes(newOption.value)) onValueChange?.([...current, newOption.value]);
|
|
1927
|
+
} else if (newOption) {
|
|
1928
|
+
onValueChange?.(newOption.value);
|
|
1922
1929
|
}
|
|
1930
|
+
// A void onCreate hands creation to the host (a dialog, a server round
|
|
1931
|
+
// trip); a single-value panel left open would sit over that UI.
|
|
1932
|
+
if (!multiple) setOpen(false);
|
|
1923
1933
|
setInternalFilter("");
|
|
1924
|
-
}, [onCreate, filter, multiple, value, onValueChange]);
|
|
1934
|
+
}, [onCreate, filter, multiple, value, onValueChange, setOpen]);
|
|
1925
1935
|
|
|
1926
1936
|
// Async-search comboboxes (Awesomebar, Link fields) open with few or no
|
|
1927
1937
|
// options and stream results as the user types — open the sheet tall
|
|
@@ -2639,6 +2649,25 @@ export function Combobox({
|
|
|
2639
2649
|
defaultValue,
|
|
2640
2650
|
);
|
|
2641
2651
|
const isControlled = valueProp !== undefined;
|
|
2652
|
+
const [createdOptions, setCreatedOptions] = useState<ComboboxOption[]>([]);
|
|
2653
|
+
const knownOptions = useMemo(() => {
|
|
2654
|
+
const unlisted = createdOptions.filter((c) => !options.some((o) => o.value === c.value));
|
|
2655
|
+
return unlisted.length ? [...options, ...unlisted] : options;
|
|
2656
|
+
}, [options, createdOptions]);
|
|
2657
|
+
const handleCreateOption = useMemo(
|
|
2658
|
+
() =>
|
|
2659
|
+
onCreate &&
|
|
2660
|
+
((typed: string) => {
|
|
2661
|
+
const created = onCreate(typed);
|
|
2662
|
+
if (created) {
|
|
2663
|
+
setCreatedOptions((prev) =>
|
|
2664
|
+
prev.some((o) => o.value === created.value) ? prev : [...prev, created],
|
|
2665
|
+
);
|
|
2666
|
+
}
|
|
2667
|
+
return created;
|
|
2668
|
+
}),
|
|
2669
|
+
[onCreate],
|
|
2670
|
+
);
|
|
2642
2671
|
const viewportGtSm = useViewportGtSm();
|
|
2643
2672
|
// Sheet on native and below `$sm` (viewport < 640, OVERLAY_BREAKPOINT).
|
|
2644
2673
|
// Floating from `$sm` up.
|
|
@@ -2659,7 +2688,7 @@ export function Combobox({
|
|
|
2659
2688
|
const renderReadOnly = (selectValue: string | string[] | undefined) => {
|
|
2660
2689
|
if (multiple && Array.isArray(selectValue)) {
|
|
2661
2690
|
const labels = selectValue
|
|
2662
|
-
.map((v) =>
|
|
2691
|
+
.map((v) => knownOptions.find((o) => o.value === v)?.label ?? v)
|
|
2663
2692
|
.join(", ");
|
|
2664
2693
|
return (
|
|
2665
2694
|
<Text
|
|
@@ -2675,7 +2704,7 @@ export function Combobox({
|
|
|
2675
2704
|
);
|
|
2676
2705
|
}
|
|
2677
2706
|
const sv = typeof selectValue === "string" ? selectValue : undefined;
|
|
2678
|
-
const selected =
|
|
2707
|
+
const selected = knownOptions.find((option) => option.value === sv);
|
|
2679
2708
|
const displayText = selected?.label ?? (sv ? (valueLabel ?? sv) : "-");
|
|
2680
2709
|
return (
|
|
2681
2710
|
<Text
|
|
@@ -2700,7 +2729,7 @@ export function Combobox({
|
|
|
2700
2729
|
const ComboboxCore = shouldUseSheet ? NativeComboboxCore : FloatingComboboxCore;
|
|
2701
2730
|
return (
|
|
2702
2731
|
<ComboboxCore
|
|
2703
|
-
options={options}
|
|
2732
|
+
options={onSearch ? options : knownOptions}
|
|
2704
2733
|
value={selectValue}
|
|
2705
2734
|
onValueChange={(value) => {
|
|
2706
2735
|
if (disabled) return;
|
|
@@ -2717,7 +2746,7 @@ export function Combobox({
|
|
|
2717
2746
|
onSearch={onSearch}
|
|
2718
2747
|
loading={loading}
|
|
2719
2748
|
searchError={searchError}
|
|
2720
|
-
valueLabel={valueLabel}
|
|
2749
|
+
valueLabel={valueLabel ?? createdOptions.find((o) => o.value === selectValue)?.label}
|
|
2721
2750
|
clearable={clearable}
|
|
2722
2751
|
onLoadMore={onLoadMore}
|
|
2723
2752
|
hasMore={hasMore}
|
|
@@ -2734,7 +2763,7 @@ export function Combobox({
|
|
|
2734
2763
|
selectedOrder={selectedOrder}
|
|
2735
2764
|
dismissible={dismissible}
|
|
2736
2765
|
creatable={creatable}
|
|
2737
|
-
onCreate={
|
|
2766
|
+
onCreate={handleCreateOption}
|
|
2738
2767
|
createLabel={createLabel}
|
|
2739
2768
|
hideSearch={hideSearch}
|
|
2740
2769
|
searchValue={searchValue}
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* MPO-356 / LC-91 SWITCH-FOOTPRINT, native half. Clay's iPhone measured the
|
|
3
|
-
* default track at about 85 × 43 pt;
|
|
4
|
-
* thumb
|
|
5
|
-
*
|
|
3
|
+
* default track at about 85 × 43 pt; the track is UISwitch's 51 × 31, and the
|
|
4
|
+
* thumb follows Tamagui's proportion, filling the track inside the 1 pt
|
|
5
|
+
* hairline (29 pt) as it does on web. Mock tamagui's isWeb so the native
|
|
6
|
+
* branch runs on the test host renderer (RadioGroup.native.spec pattern) and
|
|
7
|
+
* read the painted track back.
|
|
6
8
|
*/
|
|
7
9
|
|
|
8
10
|
import { cleanup } from "@testing-library/react";
|
|
@@ -36,10 +38,10 @@ function mount(overrides: Record<string, unknown> = {}, minTargetHeight?: number
|
|
|
36
38
|
}
|
|
37
39
|
|
|
38
40
|
describe("Switch footprint on native (MPO-356)", () => {
|
|
39
|
-
it("paints a UISwitch-sized 51 × 31 track with a
|
|
41
|
+
it("paints a UISwitch-sized 51 × 31 track with a 29 pt thumb at default knobs", () => {
|
|
40
42
|
const { track, thumb } = mount();
|
|
41
43
|
expect(track).toEqual({ width: 51, height: 31 });
|
|
42
|
-
expect(thumb).toEqual({ width:
|
|
44
|
+
expect(thumb).toEqual({ width: 29, height: 29 });
|
|
43
45
|
});
|
|
44
46
|
|
|
45
47
|
it("keeps the 51 × 31 paint inside a 44 pt press target on touch", () => {
|
|
@@ -55,4 +57,11 @@ describe("Switch footprint on native (MPO-356)", () => {
|
|
|
55
57
|
expect(small).toEqual({ width: 41, height: 25 });
|
|
56
58
|
expect(large).toEqual({ width: 61, height: 37 });
|
|
57
59
|
});
|
|
60
|
+
|
|
61
|
+
for (const size of ["small", "medium", "large"] as const) {
|
|
62
|
+
it(`size ${size}: the thumb fills the track inside the 1 pt hairline, Tamagui's proportion`, () => {
|
|
63
|
+
const { track, thumb } = mount({ size });
|
|
64
|
+
expect(thumb).toEqual({ width: track.height - 2, height: track.height - 2 });
|
|
65
|
+
});
|
|
66
|
+
}
|
|
58
67
|
});
|
|
@@ -8,14 +8,14 @@ import { isWeb } from "tamagui";
|
|
|
8
8
|
*
|
|
9
9
|
* - web: Tamagui's Switch, `getSwitchHeight` = size × 0.65 at a 2:1 aspect,
|
|
10
10
|
* thumb filling the track (tamagui.dev `$4` = 58 × 29, thumb 29).
|
|
11
|
-
* - native: UISwitch
|
|
12
|
-
*
|
|
11
|
+
* - native: UISwitch's 51 × 31 pt track at the 44 medium stop (Material 3's
|
|
12
|
+
* 52 × 32 sits inside the same 10 percent), Tamagui's thumb filling it: 29 pt.
|
|
13
13
|
*
|
|
14
14
|
* Painting the whole recipe height is what made `$4` an 86 × 44 slab once
|
|
15
15
|
* MPO-13 lifted the recipe from 32 to Tamagui's 44.
|
|
16
16
|
*/
|
|
17
17
|
const webSwitch = { heightRatio: 0.65, aspect: 2, rimGutter: 0 };
|
|
18
|
-
const nativeSwitch = { heightRatio: 31 / 44, aspect: 51 / 31, rimGutter:
|
|
18
|
+
const nativeSwitch = { heightRatio: 31 / 44, aspect: 51 / 31, rimGutter: 1 };
|
|
19
19
|
|
|
20
20
|
export type SwitchFootprint = {
|
|
21
21
|
trackHeight: number;
|