gbs-add-block 1.2.18 → 1.2.19
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/README.md +2 -2
- package/index.cjs +5 -0
- package/package.json +1 -1
- package/source/beta-components/number-input/README.md +53 -0
- package/source/beta-components/number-input/__tests__/core.test.ts +151 -0
- package/source/beta-components/number-input/core/format.ts +37 -0
- package/source/beta-components/number-input/core/index.ts +4 -0
- package/source/beta-components/number-input/core/parse.ts +118 -0
- package/source/beta-components/number-input/core/step.ts +73 -0
- package/source/beta-components/number-input/core/types.ts +32 -0
- package/source/beta-components/number-input/index.ts +5 -0
- package/source/beta-components/number-input/react/NumberInput.tsx +465 -0
- package/source/beta-components/number-input/react/icons.tsx +17 -0
- package/source/beta-components/number-input/react/locale.ts +7 -0
- package/source/beta-components/number-input/react/props.ts +11 -0
- package/source/beta-components/number-input/styles.css +231 -0
- package/source/beta-components/radio-group/README.md +58 -0
- package/source/beta-components/radio-group/__tests__/core.test.ts +64 -0
- package/source/beta-components/radio-group/core/group.ts +36 -0
- package/source/beta-components/radio-group/core/index.ts +2 -0
- package/source/beta-components/radio-group/core/types.ts +20 -0
- package/source/beta-components/radio-group/index.ts +9 -0
- package/source/beta-components/radio-group/react/Radio.tsx +144 -0
- package/source/beta-components/radio-group/react/RadioGroup.tsx +179 -0
- package/source/beta-components/radio-group/react/context.ts +18 -0
- package/source/beta-components/radio-group/react/locale.ts +5 -0
- package/source/beta-components/radio-group/react/props.ts +12 -0
- package/source/beta-components/radio-group/styles.css +270 -0
- package/source/beta-components/shared/__tests__/position.test.ts +143 -170
- package/source/beta-components/shared/core/position.ts +2 -15
- package/source/beta-components/shared/react/Popover.tsx +5 -18
- package/source/beta-components/switch/README.md +46 -0
- package/source/beta-components/switch/__tests__/core.test.ts +64 -0
- package/source/beta-components/switch/core/index.ts +2 -0
- package/source/beta-components/switch/core/toggle.ts +54 -0
- package/source/beta-components/switch/core/types.ts +9 -0
- package/source/beta-components/switch/index.ts +5 -0
- package/source/beta-components/switch/react/Switch.tsx +219 -0
- package/source/beta-components/switch/react/locale.ts +5 -0
- package/source/beta-components/switch/react/props.ts +12 -0
- package/source/beta-components/switch/styles.css +229 -0
|
@@ -1,170 +1,143 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
2
|
-
import { placePopover, type AnchorRect, type PlacementOptions } from "../core/position";
|
|
3
|
-
|
|
4
|
-
const VIEWPORT = { width: 1000, height: 800 };
|
|
5
|
-
|
|
6
|
-
const anchorAt = (left: number, top: number, width = 120, height = 32): AnchorRect => ({
|
|
7
|
-
left,
|
|
8
|
-
top,
|
|
9
|
-
width,
|
|
10
|
-
height,
|
|
11
|
-
right: left + width,
|
|
12
|
-
bottom: top + height,
|
|
13
|
-
});
|
|
14
|
-
|
|
15
|
-
const options = (overrides: Partial<PlacementOptions> = {}): PlacementOptions => ({
|
|
16
|
-
side: "bottom",
|
|
17
|
-
align: "start",
|
|
18
|
-
gap: 4,
|
|
19
|
-
edge: 8,
|
|
20
|
-
rtl: false,
|
|
21
|
-
fitHeight: false,
|
|
22
|
-
minHeight: 140,
|
|
23
|
-
...overrides,
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
describe("below the anchor", () => {
|
|
27
|
-
it("sits under the anchor, aligned to its left edge", () => {
|
|
28
|
-
const placement = placePopover(anchorAt(100, 100), { width: 200, height: 150 }, VIEWPORT, options());
|
|
29
|
-
expect(placement).toMatchObject({ side: "bottom", left: 100, top: 136 });
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
it("aligns to the right edge when asked", () => {
|
|
33
|
-
const placement = placePopover(
|
|
34
|
-
anchorAt(100, 100),
|
|
35
|
-
{ width: 200, height: 150 },
|
|
36
|
-
VIEWPORT,
|
|
37
|
-
options({ align: "end" }),
|
|
38
|
-
);
|
|
39
|
-
// The anchor's right edge is 220, so a 200-wide panel starts at 20.
|
|
40
|
-
expect(placement.left).toBe(20);
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
it("moves above when there is no room below and it fits above", () => {
|
|
44
|
-
const placement = placePopover(anchorAt(100, 700), { width: 200, height: 150 }, VIEWPORT, options());
|
|
45
|
-
expect(placement).toMatchObject({ side: "top", top: 546 });
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
it("stays below when it fits neither side, rather than running off the top", () => {
|
|
49
|
-
const placement = placePopover(anchorAt(100, 300), { width: 200, height: 780 }, VIEWPORT, options());
|
|
50
|
-
expect(placement.side).toBe("bottom");
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
//
|
|
57
|
-
const placement = placePopover(
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
expect(placement.
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
expect(placement
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
it("keeps
|
|
112
|
-
const placement = placePopover(
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
);
|
|
145
|
-
expect(placement.top).toBe(800 - 400 - 8);
|
|
146
|
-
});
|
|
147
|
-
});
|
|
148
|
-
|
|
149
|
-
describe("right to left", () => {
|
|
150
|
-
it("mirrors which edge `start` means", () => {
|
|
151
|
-
const placement = placePopover(
|
|
152
|
-
anchorAt(100, 100),
|
|
153
|
-
{ width: 200, height: 150 },
|
|
154
|
-
VIEWPORT,
|
|
155
|
-
options({ rtl: true }),
|
|
156
|
-
);
|
|
157
|
-
// Reading starts on the right, so the panel's right edge meets the anchor's.
|
|
158
|
-
expect(placement.left).toBe(20);
|
|
159
|
-
});
|
|
160
|
-
|
|
161
|
-
it("mirrors left and right", () => {
|
|
162
|
-
const placement = placePopover(
|
|
163
|
-
anchorAt(500, 100),
|
|
164
|
-
{ width: 200, height: 150 },
|
|
165
|
-
VIEWPORT,
|
|
166
|
-
options({ side: "right", rtl: true }),
|
|
167
|
-
);
|
|
168
|
-
expect(placement).toMatchObject({ side: "left", left: 296 });
|
|
169
|
-
});
|
|
170
|
-
});
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { placePopover, type AnchorRect, type PlacementOptions } from "../core/position";
|
|
3
|
+
|
|
4
|
+
const VIEWPORT = { width: 1000, height: 800 };
|
|
5
|
+
|
|
6
|
+
const anchorAt = (left: number, top: number, width = 120, height = 32): AnchorRect => ({
|
|
7
|
+
left,
|
|
8
|
+
top,
|
|
9
|
+
width,
|
|
10
|
+
height,
|
|
11
|
+
right: left + width,
|
|
12
|
+
bottom: top + height,
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const options = (overrides: Partial<PlacementOptions> = {}): PlacementOptions => ({
|
|
16
|
+
side: "bottom",
|
|
17
|
+
align: "start",
|
|
18
|
+
gap: 4,
|
|
19
|
+
edge: 8,
|
|
20
|
+
rtl: false,
|
|
21
|
+
fitHeight: false,
|
|
22
|
+
minHeight: 140,
|
|
23
|
+
...overrides,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe("below the anchor", () => {
|
|
27
|
+
it("sits under the anchor, aligned to its left edge", () => {
|
|
28
|
+
const placement = placePopover(anchorAt(100, 100), { width: 200, height: 150 }, VIEWPORT, options());
|
|
29
|
+
expect(placement).toMatchObject({ side: "bottom", left: 100, top: 136 });
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("aligns to the right edge when asked", () => {
|
|
33
|
+
const placement = placePopover(
|
|
34
|
+
anchorAt(100, 100),
|
|
35
|
+
{ width: 200, height: 150 },
|
|
36
|
+
VIEWPORT,
|
|
37
|
+
options({ align: "end" }),
|
|
38
|
+
);
|
|
39
|
+
// The anchor's right edge is 220, so a 200-wide panel starts at 20.
|
|
40
|
+
expect(placement.left).toBe(20);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("moves above when there is no room below and it fits above", () => {
|
|
44
|
+
const placement = placePopover(anchorAt(100, 700), { width: 200, height: 150 }, VIEWPORT, options());
|
|
45
|
+
expect(placement).toMatchObject({ side: "top", top: 546 });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("stays below when it fits neither side, rather than running off the top", () => {
|
|
49
|
+
const placement = placePopover(anchorAt(100, 300), { width: 200, height: 780 }, VIEWPORT, options());
|
|
50
|
+
expect(placement.side).toBe("bottom");
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe("fitHeight", () => {
|
|
55
|
+
it("takes the roomier side and caps the height to it", () => {
|
|
56
|
+
// 700px from the top: 68px below, 688px above.
|
|
57
|
+
const placement = placePopover(
|
|
58
|
+
anchorAt(100, 700),
|
|
59
|
+
{ width: 200, height: 600 },
|
|
60
|
+
VIEWPORT,
|
|
61
|
+
options({ fitHeight: true }),
|
|
62
|
+
);
|
|
63
|
+
expect(placement.side).toBe("top");
|
|
64
|
+
expect(placement.maxHeight).toBe(688);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("never caps below minHeight, however cramped the viewport", () => {
|
|
68
|
+
const placement = placePopover(
|
|
69
|
+
anchorAt(100, 380),
|
|
70
|
+
{ width: 200, height: 300 },
|
|
71
|
+
{ width: 1000, height: 420 },
|
|
72
|
+
options({ fitHeight: true, minHeight: 140 }),
|
|
73
|
+
);
|
|
74
|
+
expect(placement.maxHeight).toBeGreaterThanOrEqual(140);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe("staying on screen", () => {
|
|
79
|
+
it("pulls a panel back from the right edge", () => {
|
|
80
|
+
const placement = placePopover(anchorAt(950, 100), { width: 300, height: 100 }, VIEWPORT, options());
|
|
81
|
+
expect(placement.left).toBe(1000 - 300 - 8);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("keeps the left gutter when the panel is wider than the viewport", () => {
|
|
85
|
+
const placement = placePopover(anchorAt(10, 100), { width: 1200, height: 100 }, VIEWPORT, options());
|
|
86
|
+
expect(placement.left).toBe(8);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe("sideways", () => {
|
|
91
|
+
it("opens to the right of the anchor, top edges level", () => {
|
|
92
|
+
const placement = placePopover(
|
|
93
|
+
anchorAt(100, 100),
|
|
94
|
+
{ width: 200, height: 150 },
|
|
95
|
+
VIEWPORT,
|
|
96
|
+
options({ side: "right" }),
|
|
97
|
+
);
|
|
98
|
+
expect(placement).toMatchObject({ side: "right", left: 224, top: 100 });
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("flips to the left when the right has no room", () => {
|
|
102
|
+
const placement = placePopover(
|
|
103
|
+
anchorAt(850, 100),
|
|
104
|
+
{ width: 200, height: 150 },
|
|
105
|
+
VIEWPORT,
|
|
106
|
+
options({ side: "right" }),
|
|
107
|
+
);
|
|
108
|
+
expect(placement).toMatchObject({ side: "left", left: 646 });
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("keeps a tall panel inside the viewport", () => {
|
|
112
|
+
const placement = placePopover(
|
|
113
|
+
anchorAt(100, 760),
|
|
114
|
+
{ width: 200, height: 400 },
|
|
115
|
+
VIEWPORT,
|
|
116
|
+
options({ side: "right" }),
|
|
117
|
+
);
|
|
118
|
+
expect(placement.top).toBe(800 - 400 - 8);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe("right to left", () => {
|
|
123
|
+
it("mirrors which edge `start` means", () => {
|
|
124
|
+
const placement = placePopover(
|
|
125
|
+
anchorAt(100, 100),
|
|
126
|
+
{ width: 200, height: 150 },
|
|
127
|
+
VIEWPORT,
|
|
128
|
+
options({ rtl: true }),
|
|
129
|
+
);
|
|
130
|
+
// Reading starts on the right, so the panel's right edge meets the anchor's.
|
|
131
|
+
expect(placement.left).toBe(20);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("mirrors left and right", () => {
|
|
135
|
+
const placement = placePopover(
|
|
136
|
+
anchorAt(500, 100),
|
|
137
|
+
{ width: 200, height: 150 },
|
|
138
|
+
VIEWPORT,
|
|
139
|
+
options({ side: "right", rtl: true }),
|
|
140
|
+
);
|
|
141
|
+
expect(placement).toMatchObject({ side: "left", left: 296 });
|
|
142
|
+
});
|
|
143
|
+
});
|
|
@@ -94,16 +94,7 @@ export function placePopover(
|
|
|
94
94
|
edge,
|
|
95
95
|
viewport.width - size.width - edge,
|
|
96
96
|
),
|
|
97
|
-
|
|
98
|
-
// pulled back inside instead of hanging off the edge. Nothing in the top
|
|
99
|
-
// layer scrolls into view, so whatever leaves the viewport is lost. A
|
|
100
|
-
// panel taller than the viewport itself still starts at the top gutter,
|
|
101
|
-
// since `clamp` keeps the minimum when the maximum falls below it.
|
|
102
|
-
top: clamp(
|
|
103
|
-
below ? anchor.bottom + gap : anchor.top - gap - size.height,
|
|
104
|
-
edge,
|
|
105
|
-
viewport.height - size.height - edge,
|
|
106
|
-
),
|
|
97
|
+
top: Math.max(edge, below ? anchor.bottom + gap : anchor.top - gap - size.height),
|
|
107
98
|
maxHeight: fitHeight
|
|
108
99
|
? Math.max(minHeight, below ? roomBelow : roomAbove)
|
|
109
100
|
: undefined,
|
|
@@ -122,11 +113,7 @@ export function placePopover(
|
|
|
122
113
|
|
|
123
114
|
return {
|
|
124
115
|
side: right ? "right" : "left",
|
|
125
|
-
left:
|
|
126
|
-
right ? anchor.right + gap : anchor.left - gap - size.width,
|
|
127
|
-
edge,
|
|
128
|
-
viewport.width - size.width - edge,
|
|
129
|
-
),
|
|
116
|
+
left: Math.max(edge, right ? anchor.right + gap : anchor.left - gap - size.width),
|
|
130
117
|
top: clamp(
|
|
131
118
|
across(anchor.top, anchor.height, size.height),
|
|
132
119
|
edge,
|
|
@@ -137,30 +137,17 @@ export function AnchoredPopover({
|
|
|
137
137
|
target?.focus({ preventScroll: true });
|
|
138
138
|
}
|
|
139
139
|
|
|
140
|
-
|
|
141
|
-
// measures the anchor and writes styles back, so repositioning is coalesced
|
|
142
|
-
// into one frame rather than thrashing layout on every event.
|
|
143
|
-
let frame = 0;
|
|
144
|
-
const schedule = () => {
|
|
145
|
-
if (frame) return;
|
|
146
|
-
frame = requestAnimationFrame(() => {
|
|
147
|
-
frame = 0;
|
|
148
|
-
position();
|
|
149
|
-
});
|
|
150
|
-
};
|
|
151
|
-
|
|
152
|
-
const observer = observeResize ? new ResizeObserver(schedule) : null;
|
|
140
|
+
const observer = observeResize ? new ResizeObserver(position) : null;
|
|
153
141
|
observer?.observe(el);
|
|
154
142
|
if (observeResize === "both") observer?.observe(anchor);
|
|
155
|
-
window.addEventListener("resize",
|
|
143
|
+
window.addEventListener("resize", position);
|
|
156
144
|
// Capture: a scroll in any ancestor moves the anchor, not just the page.
|
|
157
|
-
window.addEventListener("scroll",
|
|
145
|
+
window.addEventListener("scroll", position, true);
|
|
158
146
|
|
|
159
147
|
return () => {
|
|
160
|
-
if (frame) cancelAnimationFrame(frame);
|
|
161
148
|
observer?.disconnect();
|
|
162
|
-
window.removeEventListener("resize",
|
|
163
|
-
window.removeEventListener("scroll",
|
|
149
|
+
window.removeEventListener("resize", position);
|
|
150
|
+
window.removeEventListener("scroll", position, true);
|
|
164
151
|
const hadFocus = el.contains(document.activeElement);
|
|
165
152
|
if (el.matches(":popover-open")) el.hidePopover();
|
|
166
153
|
if (returnFocus && (hadFocus || document.activeElement === document.body) && anchor.isConnected) {
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# Switch
|
|
2
|
+
|
|
3
|
+
A setting that takes effect the moment it is flipped, for React 19, styled to
|
|
4
|
+
match the rest of the library. The control is a real
|
|
5
|
+
`<input type="checkbox" role="switch">`, so forms, form libraries and assistive
|
|
6
|
+
technology work as they do natively — and it is announced as "on"/"off" rather
|
|
7
|
+
than "checked". No runtime dependencies besides React.
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import { Switch } from "@/components/switch";
|
|
11
|
+
import "@/components/switch/styles.css";
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
```tsx
|
|
15
|
+
<Switch label="Email notifications" name="notify" checked={on} onCheckedChange={setOn} />
|
|
16
|
+
|
|
17
|
+
{/* Return a promise and the switch shows the new setting, spins while it saves,
|
|
18
|
+
and puts itself back if the save fails. */}
|
|
19
|
+
<Switch
|
|
20
|
+
label="Two-factor authentication"
|
|
21
|
+
description="Ask for a code from your authenticator app."
|
|
22
|
+
checked={enabled}
|
|
23
|
+
onCheckedChange={(next) => api.setTwoFactor(next)}
|
|
24
|
+
/>
|
|
25
|
+
|
|
26
|
+
{/* A settings row: label first, switch against the right edge. */}
|
|
27
|
+
<Switch label="Weekly digest" labelPosition="start" defaultChecked />
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
**Props:** `checked` / `defaultChecked` / `onCheckedChange` (may return a
|
|
31
|
+
promise), `value`, `label`, `description`, `error`, `size` (`sm` `md` `lg`),
|
|
32
|
+
`labelPosition` (`end` `start`), `loading`, `disabled`, `readOnly`, `required`,
|
|
33
|
+
`name`, `classNames` (`root` `control` `input` `track` `thumb` `label`
|
|
34
|
+
`description` `error`), `localeText`, `ref` (the `<input>`), and every
|
|
35
|
+
`<input>` attribute.
|
|
36
|
+
|
|
37
|
+
**Keyboard:** **Space** toggles. **→** turns it on and **←** off (mirrored in
|
|
38
|
+
right-to-left layouts), which is the ARIA switch pattern.
|
|
39
|
+
|
|
40
|
+
The `idleToggle`, `request`, `settle` and `adopt` state machine behind the
|
|
41
|
+
saving behaviour is exported from the framework-free `core`: it decides what a
|
|
42
|
+
failed save goes back to, and ignores the answer to a request that a later one
|
|
43
|
+
has already replaced.
|
|
44
|
+
|
|
45
|
+
Use a **Checkbox** instead when the value is part of a form that is submitted
|
|
46
|
+
later, and a Switch when flipping it *is* the action.
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { adopt, idleToggle, request, settle } from "../core/toggle";
|
|
3
|
+
|
|
4
|
+
describe("switch toggling", () => {
|
|
5
|
+
it("shows the new setting before it has been saved", () => {
|
|
6
|
+
const state = request(idleToggle(false), true);
|
|
7
|
+
expect(state).toEqual({ checked: true, pending: true, fallback: false });
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("keeps the new setting once the save succeeds", () => {
|
|
11
|
+
const state = settle(request(idleToggle(false), true), true, true);
|
|
12
|
+
expect(state).toEqual({ checked: true, pending: null, fallback: true });
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("goes back where it was when the save fails", () => {
|
|
16
|
+
const state = settle(request(idleToggle(false), true), true, false);
|
|
17
|
+
expect(state).toEqual({ checked: false, pending: null, fallback: false });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("returns to where the run of changes started, not to the opposite", () => {
|
|
21
|
+
// On, then off again while the first save is still running: failing the
|
|
22
|
+
// second must land on the original off, which is also where it looks like
|
|
23
|
+
// it is — flipping the visible value would turn it back on.
|
|
24
|
+
const first = request(idleToggle(false), true);
|
|
25
|
+
const second = request(first, false);
|
|
26
|
+
expect(second.fallback).toBe(false);
|
|
27
|
+
expect(settle(second, false, false).checked).toBe(false);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("ignores the outcome of a change that was overtaken", () => {
|
|
31
|
+
const first = request(idleToggle(false), true);
|
|
32
|
+
const second = request(first, false);
|
|
33
|
+
// The first request finally answers, long after the second replaced it.
|
|
34
|
+
expect(settle(second, true, true)).toBe(second);
|
|
35
|
+
expect(settle(second, true, false)).toBe(second);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("ignores an outcome when nothing is in flight", () => {
|
|
39
|
+
const idle = idleToggle(true);
|
|
40
|
+
expect(settle(idle, true, false)).toBe(idle);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("does nothing when asked for the value it already shows", () => {
|
|
44
|
+
const idle = idleToggle(true);
|
|
45
|
+
expect(request(idle, true)).toBe(idle);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("re-requests a value that is showing but not yet saved", () => {
|
|
49
|
+
// Pressed on, pressed off, pressed on again: the last press still needs an
|
|
50
|
+
// outcome of its own, so it cannot be dropped as a no-op.
|
|
51
|
+
const pending = request(request(idleToggle(false), true), false);
|
|
52
|
+
const again = request(pending, true);
|
|
53
|
+
expect(again.pending).toBe(true);
|
|
54
|
+
expect(again.fallback).toBe(false);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("lets a controlled value win over anything in flight", () => {
|
|
58
|
+
const pending = request(idleToggle(false), true);
|
|
59
|
+
expect(adopt(pending, false)).toEqual({ checked: false, pending: null, fallback: false });
|
|
60
|
+
// An unchanged, settled value is left as it is, so React skips the render.
|
|
61
|
+
const idle = idleToggle(true);
|
|
62
|
+
expect(adopt(idle, true)).toBe(idle);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The state of a switch whose change has to be saved somewhere.
|
|
3
|
+
*
|
|
4
|
+
* A switch is usually the whole interaction: there is no form to submit
|
|
5
|
+
* afterwards, so flipping it *is* the request. That leaves three things to
|
|
6
|
+
* agree on, and they are worth keeping out of the component and under test.
|
|
7
|
+
*
|
|
8
|
+
* The switch moves as soon as it is pressed, rather than waiting for the
|
|
9
|
+
* server, because a control that ignores a press feels broken. If the save
|
|
10
|
+
* fails it goes back to where it was — to `fallback`, the value from before the
|
|
11
|
+
* press, not simply the opposite of what is on screen, since the two differ
|
|
12
|
+
* once anything else has changed the value in the meantime.
|
|
13
|
+
*
|
|
14
|
+
* And a request that is overtaken is ignored: `settle` only accepts an outcome
|
|
15
|
+
* for the change still in flight (`pending`), so a late failure from an
|
|
16
|
+
* abandoned request cannot drag a newer value back with it.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export interface ToggleState {
|
|
20
|
+
/** What the switch shows now. */
|
|
21
|
+
checked: boolean;
|
|
22
|
+
/** The change being saved, or null when nothing is in flight. */
|
|
23
|
+
pending: boolean | null;
|
|
24
|
+
/** What to go back to if the save fails. */
|
|
25
|
+
fallback: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const idleToggle = (checked: boolean): ToggleState => ({
|
|
29
|
+
checked,
|
|
30
|
+
pending: null,
|
|
31
|
+
fallback: checked,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
/** Shows `next` at once and remembers where to return to. */
|
|
35
|
+
export function request(state: ToggleState, next: boolean): ToggleState {
|
|
36
|
+
if (next === state.checked && state.pending === null) return state;
|
|
37
|
+
return { checked: next, pending: next, fallback: state.pending === null ? state.checked : state.fallback };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Applies the outcome of the change that is in flight. An outcome for anything
|
|
42
|
+
* else — a request that was already overtaken — leaves the state alone.
|
|
43
|
+
*/
|
|
44
|
+
export function settle(state: ToggleState, requested: boolean, saved: boolean): ToggleState {
|
|
45
|
+
if (state.pending === null || state.pending !== requested) return state;
|
|
46
|
+
const checked = saved ? state.checked : state.fallback;
|
|
47
|
+
return { checked, pending: null, fallback: checked };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** A value arriving from a controlled prop wins, and ends anything in flight. */
|
|
51
|
+
export function adopt(state: ToggleState, checked: boolean): ToggleState {
|
|
52
|
+
if (checked === state.checked && state.pending === null) return state;
|
|
53
|
+
return idleToggle(checked);
|
|
54
|
+
}
|