@multiplatform.one/frappe-ui 7.78.0 → 7.79.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/FrappeForm.cjs +20 -26
- package/dist/cjs/FrappeForm.native.js +26 -26
- package/dist/cjs/FrappeForm.native.js.map +1 -1
- package/dist/cjs/FrappeListView.cjs +26 -12
- package/dist/cjs/FrappeListView.native.js +41 -12
- package/dist/cjs/FrappeListView.native.js.map +1 -1
- package/dist/cjs/fields/Password/index.cjs +125 -36
- package/dist/cjs/fields/Password/index.native.js +101 -13
- package/dist/cjs/fields/Password/index.native.js.map +1 -1
- package/dist/cjs/list/ListControlsSheet.cjs +5 -11
- package/dist/cjs/list/ListControlsSheet.native.js +5 -11
- package/dist/cjs/list/ListControlsSheet.native.js.map +1 -1
- package/dist/cjs/list/ListFiltersButton.cjs +56 -0
- package/dist/cjs/list/ListFiltersButton.native.js +60 -0
- package/dist/cjs/list/ListFiltersButton.native.js.map +1 -0
- package/dist/cjs/list/ListSidebarToolbar.cjs +5 -17
- package/dist/cjs/list/ListSidebarToolbar.native.js +7 -19
- package/dist/cjs/list/ListSidebarToolbar.native.js.map +1 -1
- package/dist/cjs/list/ListToolsSheet.cjs +5 -18
- package/dist/cjs/list/ListToolsSheet.native.js +7 -20
- package/dist/cjs/list/ListToolsSheet.native.js.map +1 -1
- package/dist/cjs/list/defaultProjection.cjs +56 -0
- package/dist/cjs/list/defaultProjection.native.js +107 -0
- package/dist/cjs/list/defaultProjection.native.js.map +1 -0
- package/dist/cjs/shared/devWarn.native.js.map +1 -1
- package/dist/esm/FrappeForm.mjs +21 -27
- package/dist/esm/FrappeForm.mjs.map +1 -1
- package/dist/esm/FrappeForm.native.js +27 -27
- package/dist/esm/FrappeForm.native.js.map +1 -1
- package/dist/esm/FrappeListView.mjs +26 -12
- package/dist/esm/FrappeListView.mjs.map +1 -1
- package/dist/esm/FrappeListView.native.js +41 -12
- package/dist/esm/FrappeListView.native.js.map +1 -1
- package/dist/esm/fields/Password/index.mjs +125 -36
- package/dist/esm/fields/Password/index.mjs.map +1 -1
- package/dist/esm/fields/Password/index.native.js +101 -13
- package/dist/esm/fields/Password/index.native.js.map +1 -1
- package/dist/esm/list/ListControlsSheet.mjs +7 -13
- package/dist/esm/list/ListControlsSheet.mjs.map +1 -1
- package/dist/esm/list/ListControlsSheet.native.js +7 -13
- package/dist/esm/list/ListControlsSheet.native.js.map +1 -1
- package/dist/esm/list/ListFiltersButton.mjs +31 -0
- package/dist/esm/list/ListFiltersButton.mjs.map +1 -0
- package/dist/esm/list/ListFiltersButton.native.js +32 -0
- package/dist/esm/list/ListFiltersButton.native.js.map +1 -0
- package/dist/esm/list/ListSidebarToolbar.mjs +7 -19
- package/dist/esm/list/ListSidebarToolbar.mjs.map +1 -1
- package/dist/esm/list/ListSidebarToolbar.native.js +9 -21
- package/dist/esm/list/ListSidebarToolbar.native.js.map +1 -1
- package/dist/esm/list/ListToolsSheet.mjs +7 -20
- package/dist/esm/list/ListToolsSheet.mjs.map +1 -1
- package/dist/esm/list/ListToolsSheet.native.js +9 -22
- package/dist/esm/list/ListToolsSheet.native.js.map +1 -1
- package/dist/esm/list/defaultProjection.mjs +29 -0
- package/dist/esm/list/defaultProjection.mjs.map +1 -0
- package/dist/esm/list/defaultProjection.native.js +77 -0
- package/dist/esm/list/defaultProjection.native.js.map +1 -0
- package/dist/esm/shared/devWarn.mjs.map +1 -1
- package/dist/esm/shared/devWarn.native.js.map +1 -1
- package/dist/jsx/FrappeForm.mjs +21 -27
- package/dist/jsx/FrappeForm.mjs.map +1 -1
- package/dist/jsx/FrappeForm.native.js +26 -26
- package/dist/jsx/FrappeForm.native.js.map +1 -1
- package/dist/jsx/FrappeListView.mjs +26 -12
- package/dist/jsx/FrappeListView.mjs.map +1 -1
- package/dist/jsx/FrappeListView.native.js +41 -12
- package/dist/jsx/FrappeListView.native.js.map +1 -1
- package/dist/jsx/fields/Password/index.mjs +125 -36
- package/dist/jsx/fields/Password/index.mjs.map +1 -1
- package/dist/jsx/fields/Password/index.native.js +101 -13
- package/dist/jsx/fields/Password/index.native.js.map +1 -1
- package/dist/jsx/list/ListControlsSheet.mjs +7 -13
- package/dist/jsx/list/ListControlsSheet.mjs.map +1 -1
- package/dist/jsx/list/ListControlsSheet.native.js +5 -11
- package/dist/jsx/list/ListControlsSheet.native.js.map +1 -1
- package/dist/jsx/list/ListFiltersButton.mjs +31 -0
- package/dist/jsx/list/ListFiltersButton.mjs.map +1 -0
- package/dist/jsx/list/ListFiltersButton.native.js +60 -0
- package/dist/jsx/list/ListFiltersButton.native.js.map +1 -0
- package/dist/jsx/list/ListSidebarToolbar.mjs +7 -19
- package/dist/jsx/list/ListSidebarToolbar.mjs.map +1 -1
- package/dist/jsx/list/ListSidebarToolbar.native.js +7 -19
- package/dist/jsx/list/ListSidebarToolbar.native.js.map +1 -1
- package/dist/jsx/list/ListToolsSheet.mjs +7 -20
- package/dist/jsx/list/ListToolsSheet.mjs.map +1 -1
- package/dist/jsx/list/ListToolsSheet.native.js +7 -20
- package/dist/jsx/list/ListToolsSheet.native.js.map +1 -1
- package/dist/jsx/list/defaultProjection.mjs +29 -0
- package/dist/jsx/list/defaultProjection.mjs.map +1 -0
- package/dist/jsx/list/defaultProjection.native.js +107 -0
- package/dist/jsx/list/defaultProjection.native.js.map +1 -0
- package/dist/jsx/shared/devWarn.mjs.map +1 -1
- package/dist/jsx/shared/devWarn.native.js.map +1 -1
- package/package.json +16 -16
- package/src/FrappeForm.recordChanged.spec.tsx +179 -0
- package/src/FrappeForm.recordChanged.stories.tsx +83 -0
- package/src/FrappeForm.successIsCallback.spec.tsx +154 -0
- package/src/FrappeForm.tsx +34 -33
- package/src/FrappeListView.projection.spec.tsx +193 -0
- package/src/FrappeListView.sidebar.spec.tsx +20 -2
- package/src/FrappeListView.successIsCallback.spec.tsx +143 -0
- package/src/FrappeListView.tsx +63 -25
- package/src/fields/Password/Password.secret.spec.tsx +194 -0
- package/src/fields/Password/Password.spec.tsx +15 -8
- package/src/fields/Password/index.tsx +134 -35
- package/src/list/ListControls.spec.tsx +10 -1
- package/src/list/ListControlsSheet.spec.tsx +4 -2
- package/src/list/ListControlsSheet.tsx +5 -9
- package/src/list/ListFiltersButton.tsx +33 -0
- package/src/list/ListSidebarToolbar.tsx +13 -20
- package/src/list/ListToolsSheet.tsx +10 -18
- package/src/list/defaultProjection.ts +66 -0
- package/src/shared/devWarn.ts +2 -1
- package/types/FrappeForm.d.ts.map +1 -1
- package/types/FrappeListView.d.ts.map +1 -1
- package/types/fields/Password/index.d.ts +6 -0
- package/types/fields/Password/index.d.ts.map +1 -1
- package/types/list/ListControlsSheet.d.ts.map +1 -1
- package/types/list/ListFiltersButton.d.ts +11 -0
- package/types/list/ListFiltersButton.d.ts.map +1 -0
- package/types/list/ListSidebarToolbar.d.ts +5 -3
- package/types/list/ListSidebarToolbar.d.ts.map +1 -1
- package/types/list/ListToolsSheet.d.ts.map +1 -1
- package/types/list/defaultProjection.d.ts +26 -0
- package/types/list/defaultProjection.d.ts.map +1 -0
- package/types/shared/devWarn.d.ts +1 -1
- package/types/shared/devWarn.d.ts.map +1 -1
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LC-186 SECRET-VALUES: a stored secret is written, never read back. The
|
|
3
|
+
* field is handed the record's stored value and must not put it in any
|
|
4
|
+
* rendered text, attribute, input value or accessible name, at rest, in
|
|
5
|
+
* error, while loading or disabled. Replace opens an empty masked input;
|
|
6
|
+
* the arrow puts the stored secret back without showing it.
|
|
7
|
+
*/
|
|
8
|
+
import { Form } from "@multiplatform.one/forms";
|
|
9
|
+
import { renderWithProviders } from "@multiplatform.one/test-utils";
|
|
10
|
+
import { cleanup, fireEvent } from "@testing-library/react";
|
|
11
|
+
import i18next from "i18next";
|
|
12
|
+
import { useState } from "react";
|
|
13
|
+
import { initReactI18next } from "react-i18next";
|
|
14
|
+
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
|
15
|
+
import { Password } from "./index";
|
|
16
|
+
|
|
17
|
+
const secret = "s3cr3t-Hunter-42";
|
|
18
|
+
|
|
19
|
+
beforeAll(async () => {
|
|
20
|
+
await i18next.use(initReactI18next).init({
|
|
21
|
+
lng: "en",
|
|
22
|
+
interpolation: { escapeValue: false },
|
|
23
|
+
resources: { en: { translation: {} } },
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
afterEach(cleanup);
|
|
28
|
+
|
|
29
|
+
function leaks(root: HTMLElement, value: string): string[] {
|
|
30
|
+
const found: string[] = [];
|
|
31
|
+
if ((root.textContent ?? "").includes(value)) found.push("text");
|
|
32
|
+
for (const el of [root, ...root.querySelectorAll("*")]) {
|
|
33
|
+
for (const attr of el.getAttributeNames()) {
|
|
34
|
+
if ((el.getAttribute(attr) ?? "").includes(value)) found.push(`${el.tagName}@${attr}`);
|
|
35
|
+
}
|
|
36
|
+
if (el instanceof HTMLInputElement && el.value.includes(value)) found.push("input.value");
|
|
37
|
+
}
|
|
38
|
+
return found;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function statusOf(container: HTMLElement) {
|
|
42
|
+
return container.querySelector("[data-secret-state='set']");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function action(container: HTMLElement, label: RegExp) {
|
|
46
|
+
return [...container.querySelectorAll("[aria-label]")].find((el) =>
|
|
47
|
+
label.test(el.getAttribute("aria-label") ?? ""),
|
|
48
|
+
) as HTMLElement | undefined;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe("Password is write-only (LC-186)", () => {
|
|
52
|
+
const states = {
|
|
53
|
+
"at rest": {},
|
|
54
|
+
"in error": { error: "Wrong password" },
|
|
55
|
+
loading: { skeleton: true },
|
|
56
|
+
disabled: { disabled: true },
|
|
57
|
+
"read-only": { readOnly: true },
|
|
58
|
+
} as const;
|
|
59
|
+
|
|
60
|
+
for (const [state, extra] of Object.entries(states)) {
|
|
61
|
+
it(`never renders a stored secret ${state}`, () => {
|
|
62
|
+
const result = renderWithProviders(<Password label="API key" value={secret} {...extra} />);
|
|
63
|
+
expect(leaks(document.body, secret)).toEqual([]);
|
|
64
|
+
if (!("skeleton" in extra)) expect(statusOf(result.container)?.textContent).toBe("Set");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it(`never renders a form's stored secret ${state}`, () => {
|
|
68
|
+
renderWithProviders(
|
|
69
|
+
<Form onSubmit={() => {}}>
|
|
70
|
+
<Password name="api_key" label="API key" defaultValue={secret} {...extra} />
|
|
71
|
+
</Form>,
|
|
72
|
+
);
|
|
73
|
+
expect(leaks(document.body, secret)).toEqual([]);
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
it("a stored secret offers Replace and Clear, a locked one neither", () => {
|
|
78
|
+
const open = renderWithProviders(<Password label="API key" value={secret} />);
|
|
79
|
+
expect(action(open.container, /^Replace API key$/)).toBeTruthy();
|
|
80
|
+
expect(action(open.container, /^Clear API key$/)).toBeTruthy();
|
|
81
|
+
expect(open.container.querySelector("input")).toBeNull();
|
|
82
|
+
open.unmount();
|
|
83
|
+
|
|
84
|
+
const locked = renderWithProviders(<Password label="API key" value={secret} readOnly />);
|
|
85
|
+
expect(action(locked.container, /^Replace/)).toBeUndefined();
|
|
86
|
+
expect(action(locked.container, /^Clear/)).toBeUndefined();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("Replace opens an empty masked input; its reveal shows only what is typed", () => {
|
|
90
|
+
const onChange = vi.fn();
|
|
91
|
+
function Harness() {
|
|
92
|
+
const [value, setValue] = useState(secret);
|
|
93
|
+
return (
|
|
94
|
+
<Password
|
|
95
|
+
label="API key"
|
|
96
|
+
value={value}
|
|
97
|
+
onChange={(next) => {
|
|
98
|
+
onChange(next);
|
|
99
|
+
setValue(next);
|
|
100
|
+
}}
|
|
101
|
+
/>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
const { container } = renderWithProviders(<Harness />);
|
|
105
|
+
fireEvent.click(action(container, /^Replace API key$/)!);
|
|
106
|
+
|
|
107
|
+
const input = container.querySelector("input") as HTMLInputElement;
|
|
108
|
+
expect(input).toBeTruthy();
|
|
109
|
+
expect(input.value).toBe("");
|
|
110
|
+
expect(input.getAttribute("type")).toBe("password");
|
|
111
|
+
expect(input.getAttribute("autocomplete")).toBe("new-password");
|
|
112
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
113
|
+
|
|
114
|
+
fireEvent.click(action(container, /^Show password$/)!);
|
|
115
|
+
expect(input.getAttribute("type")).toBe("text");
|
|
116
|
+
expect(leaks(document.body, secret)).toEqual([]);
|
|
117
|
+
|
|
118
|
+
fireEvent.change(input, { target: { value: "fresh-key" } });
|
|
119
|
+
expect(onChange).toHaveBeenLastCalledWith("fresh-key");
|
|
120
|
+
expect((container.querySelector("input") as HTMLInputElement).value).toBe("fresh-key");
|
|
121
|
+
expect(leaks(document.body, secret)).toEqual([]);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("the arrow keeps the saved secret: the value goes back, still unseen", () => {
|
|
125
|
+
const onChange = vi.fn();
|
|
126
|
+
function Harness() {
|
|
127
|
+
const [value, setValue] = useState(secret);
|
|
128
|
+
return (
|
|
129
|
+
<Password
|
|
130
|
+
label="API key"
|
|
131
|
+
value={value}
|
|
132
|
+
onChange={(next) => {
|
|
133
|
+
onChange(next);
|
|
134
|
+
setValue(next);
|
|
135
|
+
}}
|
|
136
|
+
/>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
const { container } = renderWithProviders(<Harness />);
|
|
140
|
+
fireEvent.click(action(container, /^Replace API key$/)!);
|
|
141
|
+
fireEvent.change(container.querySelector("input")!, { target: { value: "half-typed" } });
|
|
142
|
+
fireEvent.click(action(container, /^Keep the saved API key$/)!);
|
|
143
|
+
|
|
144
|
+
expect(onChange).toHaveBeenLastCalledWith(secret);
|
|
145
|
+
expect(statusOf(container)?.textContent).toBe("Set");
|
|
146
|
+
expect(container.querySelector("input")).toBeNull();
|
|
147
|
+
expect(leaks(document.body, secret)).toEqual([]);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("Clear is explicit: it writes an empty value and shows the empty input", () => {
|
|
151
|
+
const onChange = vi.fn();
|
|
152
|
+
function Harness() {
|
|
153
|
+
const [value, setValue] = useState(secret);
|
|
154
|
+
return (
|
|
155
|
+
<Password
|
|
156
|
+
label="API key"
|
|
157
|
+
value={value}
|
|
158
|
+
onChange={(next) => {
|
|
159
|
+
onChange(next);
|
|
160
|
+
setValue(next);
|
|
161
|
+
}}
|
|
162
|
+
/>
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
const { container } = renderWithProviders(<Harness />);
|
|
166
|
+
fireEvent.click(action(container, /^Clear API key$/)!);
|
|
167
|
+
|
|
168
|
+
expect(onChange).toHaveBeenLastCalledWith("");
|
|
169
|
+
expect((container.querySelector("input") as HTMLInputElement).value).toBe("");
|
|
170
|
+
expect(statusOf(container)).toBeNull();
|
|
171
|
+
expect(action(container, /^Keep the saved API key$/)).toBeTruthy();
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("a value saved over the typed one is stored again, not echoed", () => {
|
|
175
|
+
function Harness() {
|
|
176
|
+
const [value, setValue] = useState("");
|
|
177
|
+
return (
|
|
178
|
+
<>
|
|
179
|
+
<Password label="API key" value={value} onChange={setValue} />
|
|
180
|
+
<button type="button" data-testid="saved" onClick={() => setValue("*********")}>
|
|
181
|
+
saved
|
|
182
|
+
</button>
|
|
183
|
+
</>
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
const { container, getByTestId } = renderWithProviders(<Harness />);
|
|
187
|
+
fireEvent.change(container.querySelector("input")!, { target: { value: "typed-now" } });
|
|
188
|
+
expect((container.querySelector("input") as HTMLInputElement).value).toBe("typed-now");
|
|
189
|
+
|
|
190
|
+
fireEvent.click(getByTestId("saved"));
|
|
191
|
+
expect(statusOf(container)?.textContent).toBe("Set");
|
|
192
|
+
expect(leaks(document.body, "*********")).toEqual([]);
|
|
193
|
+
});
|
|
194
|
+
});
|
|
@@ -11,10 +11,17 @@ function revealOf(container: HTMLElement) {
|
|
|
11
11
|
) as HTMLElement | null;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
function typed(container: HTMLElement, text: string): HTMLInputElement {
|
|
15
|
+
const input = container.querySelector("input") as HTMLInputElement;
|
|
16
|
+
if (!input) throw new Error("input not found");
|
|
17
|
+
fireEvent.change(input, { target: { value: text } });
|
|
18
|
+
return input;
|
|
19
|
+
}
|
|
20
|
+
|
|
14
21
|
describe("Password reveal", () => {
|
|
15
22
|
it("toggle is a real tab stop and rings its own glyph, not the 44px box", () => {
|
|
16
|
-
const result = renderWithProviders(<Password label="Secret"
|
|
17
|
-
const input = result.container
|
|
23
|
+
const result = renderWithProviders(<Password label="Secret" />);
|
|
24
|
+
const input = typed(result.container, "hunter2");
|
|
18
25
|
const reveal = revealOf(result.container);
|
|
19
26
|
if (!input || !reveal) throw new Error("input or reveal not found");
|
|
20
27
|
|
|
@@ -34,8 +41,8 @@ describe("Password reveal", () => {
|
|
|
34
41
|
});
|
|
35
42
|
|
|
36
43
|
it("toggling does not move the caret or change the control geometry", () => {
|
|
37
|
-
const result = renderWithProviders(<Password label="Secret"
|
|
38
|
-
const input = result.container
|
|
44
|
+
const result = renderWithProviders(<Password label="Secret" />);
|
|
45
|
+
const input = typed(result.container, "hunter2");
|
|
39
46
|
const box = result.container.querySelector("[data-ring-target='box']") as HTMLElement;
|
|
40
47
|
const reveal = revealOf(result.container);
|
|
41
48
|
if (!input || !box || !reveal) throw new Error("anatomy missing");
|
|
@@ -57,7 +64,7 @@ describe("Password reveal", () => {
|
|
|
57
64
|
});
|
|
58
65
|
|
|
59
66
|
it("default size stays 40 tall with the toggle present (not Bento's 46)", () => {
|
|
60
|
-
const result = renderWithProviders(<Password label="Secret"
|
|
67
|
+
const result = renderWithProviders(<Password label="Secret" />);
|
|
61
68
|
const box = result.container.querySelector("[data-ring-target='box']") as HTMLElement;
|
|
62
69
|
const reveal = revealOf(result.container);
|
|
63
70
|
if (!box || !reveal) throw new Error("box or reveal not found");
|
|
@@ -69,7 +76,7 @@ describe("Password reveal", () => {
|
|
|
69
76
|
it("reveal state is announced and resets to masked when the field loses value", () => {
|
|
70
77
|
const onChange = vi.fn();
|
|
71
78
|
function Harness() {
|
|
72
|
-
const [value, setValue] = useState("
|
|
79
|
+
const [value, setValue] = useState("");
|
|
73
80
|
return (
|
|
74
81
|
<Password
|
|
75
82
|
label="Secret"
|
|
@@ -82,7 +89,7 @@ describe("Password reveal", () => {
|
|
|
82
89
|
);
|
|
83
90
|
}
|
|
84
91
|
const result = renderWithProviders(<Harness />);
|
|
85
|
-
const input = result.container
|
|
92
|
+
const input = typed(result.container, "hunter2");
|
|
86
93
|
const reveal = revealOf(result.container);
|
|
87
94
|
if (!input || !reveal) throw new Error("input or reveal not found");
|
|
88
95
|
|
|
@@ -108,7 +115,7 @@ describe("Password reveal", () => {
|
|
|
108
115
|
for (const token of SIZE_RECIPE_TOKENS) {
|
|
109
116
|
if (token === "$true") continue;
|
|
110
117
|
const height = sizeRecipeForToken(token).height;
|
|
111
|
-
const result = renderWithProviders(<Password label="Secret"
|
|
118
|
+
const result = renderWithProviders(<Password label="Secret" size={token} />);
|
|
112
119
|
const box = result.container.querySelector("[data-ring-target='box']") as HTMLElement;
|
|
113
120
|
const reveal = revealOf(result.container);
|
|
114
121
|
if (!box || !reveal) throw new Error(`anatomy missing at ${token}`);
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Password — masked Data with a reveal in the end-cap.
|
|
3
3
|
*
|
|
4
|
+
* LC-186 SECRET-VALUES: write-only. A value the field was handed (the
|
|
5
|
+
* record's stored secret, or Frappe's dummy asterisks) is never rendered:
|
|
6
|
+
* the field shows "Set" with Replace and Clear glyphs. Replace opens an empty
|
|
7
|
+
* masked input whose reveal shows only what is being typed; the arrow puts
|
|
8
|
+
* the stored secret back untouched.
|
|
9
|
+
*
|
|
4
10
|
* Bento ships no password field (0 type=password, 0 reveal toggles across
|
|
5
11
|
* 14 inputs, measured 2026-08-28, bento-pickers-measured.md §6.2). The
|
|
6
12
|
* toggle is PROPOSED. Bento's right adornment is an inert 16.8px svg
|
|
@@ -15,6 +21,7 @@ import type { FormFieldProps, FieldComponentProps } from "@multiplatform.one/for
|
|
|
15
21
|
import {
|
|
16
22
|
Field,
|
|
17
23
|
FieldLayout,
|
|
24
|
+
formCommonColors,
|
|
18
25
|
Input as FormsInput,
|
|
19
26
|
PasswordReveal,
|
|
20
27
|
Skeleton,
|
|
@@ -30,10 +37,12 @@ import {
|
|
|
30
37
|
pressTargetHitSlop,
|
|
31
38
|
sizeRecipeForToken,
|
|
32
39
|
} from "@multiplatform.one/theme";
|
|
40
|
+
import { ArrowCounterClockwiseIcon, PencilSimpleIcon, XIcon } from "@phosphor-icons/react";
|
|
33
41
|
import type { DeepKeys } from "@tanstack/form-core";
|
|
34
42
|
import type { SizeTokens, TamaguiElement } from "tamagui";
|
|
35
|
-
import { useCallback, useRef, useState } from "react";
|
|
36
|
-
import {
|
|
43
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
44
|
+
import { useTranslation } from "react-i18next";
|
|
45
|
+
import { Text, useProps } from "tamagui";
|
|
37
46
|
|
|
38
47
|
export type PasswordProps<
|
|
39
48
|
TData extends Record<string, unknown> = Record<string, unknown>,
|
|
@@ -109,11 +118,16 @@ export function Password<
|
|
|
109
118
|
}).height;
|
|
110
119
|
const belowFloor = visualHeight < MIN_PRESS_TARGET;
|
|
111
120
|
|
|
121
|
+
const { t } = useTranslation();
|
|
112
122
|
const isControlled = value !== undefined;
|
|
113
123
|
const [internalText, setInternalText] = useState(String(defaultValue ?? ""));
|
|
114
124
|
const text = isControlled ? String(value ?? "") : internalText;
|
|
115
125
|
const inputRef = useRef<TamaguiElement>(null);
|
|
116
|
-
|
|
126
|
+
// What this session typed (or cleared). Any other value is a stored secret.
|
|
127
|
+
const typedRef = useRef<string | null>(null);
|
|
128
|
+
const [replacingFrom, setReplacingFrom] = useState<string | null>(null);
|
|
129
|
+
const [typedText, setTypedText] = useState("");
|
|
130
|
+
const { revealed, toggle: toggleReveal } = usePasswordReveal({ inputRef, id, text: typedText });
|
|
117
131
|
|
|
118
132
|
const handleChangeText = useCallback(
|
|
119
133
|
(next: string) => {
|
|
@@ -124,6 +138,23 @@ export function Password<
|
|
|
124
138
|
[disabled, isControlled, onChange],
|
|
125
139
|
);
|
|
126
140
|
|
|
141
|
+
const secretOf = (current: string) => {
|
|
142
|
+
const typed = typedRef.current !== null && current === typedRef.current;
|
|
143
|
+
const stored = current !== "" && !typed;
|
|
144
|
+
return {
|
|
145
|
+
showStatus: stored && replacingFrom !== current,
|
|
146
|
+
shown: typed ? current : "",
|
|
147
|
+
};
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
useEffect(() => {
|
|
151
|
+
if (replacingFrom === null) return;
|
|
152
|
+
const el = inputRef.current as { focus?: () => void } | null;
|
|
153
|
+
el?.focus?.();
|
|
154
|
+
}, [replacingFrom]);
|
|
155
|
+
|
|
156
|
+
const fieldName = String(fieldProps.label ?? t("Password"));
|
|
157
|
+
|
|
127
158
|
const boxContract = {
|
|
128
159
|
size: resolvedSize,
|
|
129
160
|
"data-size-token": String(resolvedSize),
|
|
@@ -151,38 +182,106 @@ export function Password<
|
|
|
151
182
|
inputValue: string | undefined,
|
|
152
183
|
handleChange?: (text: string) => void,
|
|
153
184
|
hasError?: boolean,
|
|
154
|
-
) =>
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
185
|
+
) => {
|
|
186
|
+
const current = inputValue ?? "";
|
|
187
|
+
const { showStatus, shown } = secretOf(current);
|
|
188
|
+
const write = (next: string) => {
|
|
189
|
+
typedRef.current = next;
|
|
190
|
+
setTypedText(next);
|
|
191
|
+
handleChange?.(next);
|
|
192
|
+
handleChangeText(next);
|
|
193
|
+
};
|
|
194
|
+
if (showStatus) {
|
|
195
|
+
const locked = disabled || fieldProps.readOnly;
|
|
196
|
+
return (
|
|
197
|
+
<FormsInput.Box {...boxContract} disabled={disabled} paddingInlineEnd={0}>
|
|
198
|
+
<Text
|
|
199
|
+
flex={1}
|
|
200
|
+
numberOfLines={1}
|
|
201
|
+
fontFamily="$body"
|
|
202
|
+
fontSize={sizeRecipeForToken(String(resolvedSize)).fontSize}
|
|
203
|
+
color={formCommonColors.text}
|
|
204
|
+
data-secret-state="set"
|
|
205
|
+
>
|
|
206
|
+
{t("Set")}
|
|
207
|
+
</Text>
|
|
208
|
+
{locked ? null : (
|
|
209
|
+
<>
|
|
210
|
+
<FormsInput.Action
|
|
211
|
+
label={t("Replace {{field}}", { field: fieldName })}
|
|
212
|
+
size={resolvedSize}
|
|
213
|
+
onPress={() => {
|
|
214
|
+
typedRef.current = null;
|
|
215
|
+
setTypedText("");
|
|
216
|
+
setReplacingFrom(current);
|
|
217
|
+
}}
|
|
218
|
+
>
|
|
219
|
+
<PencilSimpleIcon />
|
|
220
|
+
</FormsInput.Action>
|
|
221
|
+
<FormsInput.Action
|
|
222
|
+
label={t("Clear {{field}}", { field: fieldName })}
|
|
223
|
+
size={resolvedSize}
|
|
224
|
+
onPress={() => {
|
|
225
|
+
setReplacingFrom(current);
|
|
226
|
+
write("");
|
|
227
|
+
}}
|
|
228
|
+
>
|
|
229
|
+
<XIcon />
|
|
230
|
+
</FormsInput.Action>
|
|
231
|
+
</>
|
|
232
|
+
)}
|
|
233
|
+
</FormsInput.Box>
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
const restore = replacingFrom;
|
|
237
|
+
return (
|
|
238
|
+
<FormsInput.Box {...boxContract} disabled={disabled} paddingInlineEnd={0}>
|
|
239
|
+
<FormsInput.Area
|
|
240
|
+
ref={inputRef}
|
|
241
|
+
{...(areaInputProps as object)}
|
|
242
|
+
id={id}
|
|
243
|
+
value={shown}
|
|
244
|
+
disabled={disabled}
|
|
245
|
+
readOnly={fieldProps.readOnly}
|
|
246
|
+
placeholder={placeholder}
|
|
247
|
+
type={revealed ? "text" : "password"}
|
|
248
|
+
secureTextEntry={!revealed}
|
|
249
|
+
textContentType={restore === null ? "password" : "newPassword"}
|
|
250
|
+
autoComplete={restore === null ? "current-password" : "new-password"}
|
|
251
|
+
spellCheck={false}
|
|
252
|
+
aria-required={fieldProps.required || undefined}
|
|
253
|
+
aria-invalid={hasError || undefined}
|
|
254
|
+
aria-readonly={fieldProps.readOnly || undefined}
|
|
255
|
+
onChangeText={write}
|
|
256
|
+
/>
|
|
257
|
+
<PasswordReveal
|
|
258
|
+
revealed={revealed}
|
|
259
|
+
onToggle={toggleReveal}
|
|
260
|
+
controls={id}
|
|
261
|
+
disabled={disabled}
|
|
262
|
+
size={resolvedSize}
|
|
263
|
+
/>
|
|
264
|
+
{restore === null ? null : (
|
|
265
|
+
<FormsInput.Action
|
|
266
|
+
label={t("Keep the saved {{field}}", { field: fieldName })}
|
|
267
|
+
size={resolvedSize}
|
|
268
|
+
disabled={disabled}
|
|
269
|
+
onPress={() => {
|
|
270
|
+
typedRef.current = null;
|
|
271
|
+
setTypedText("");
|
|
272
|
+
setReplacingFrom(null);
|
|
273
|
+
if (current !== restore) {
|
|
274
|
+
handleChange?.(restore);
|
|
275
|
+
handleChangeText(restore);
|
|
276
|
+
}
|
|
277
|
+
}}
|
|
278
|
+
>
|
|
279
|
+
<ArrowCounterClockwiseIcon />
|
|
280
|
+
</FormsInput.Action>
|
|
281
|
+
)}
|
|
282
|
+
</FormsInput.Box>
|
|
283
|
+
);
|
|
284
|
+
};
|
|
186
285
|
|
|
187
286
|
if (fieldProps.skeleton) {
|
|
188
287
|
return (
|
|
@@ -60,7 +60,16 @@ describe("ListControls on the compact class (639)", () => {
|
|
|
60
60
|
expect(screen.queryByRole("radiogroup", { name: "Region" })).toBeNull();
|
|
61
61
|
});
|
|
62
62
|
|
|
63
|
-
it("
|
|
63
|
+
it("draws Filters as its glyph, the name in the accessible label (FZ-8660)", () => {
|
|
64
|
+
renderAt(390);
|
|
65
|
+
const tools = screen.getByTestId("roster-tools");
|
|
66
|
+
expect(tools.getAttribute("data-mp-action")).toBe("filter");
|
|
67
|
+
expect(tools.getAttribute("aria-label")).toBe("Filters");
|
|
68
|
+
expect(tools.textContent).toBe("");
|
|
69
|
+
expect(tools.querySelector("svg")).not.toBeNull();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("badges the filter glyph with the active narrowings", () => {
|
|
64
73
|
renderAt(390, {
|
|
65
74
|
facets: { type: "fire" },
|
|
66
75
|
bands: { generation: "1" },
|
|
@@ -25,8 +25,10 @@ function renderBand(update = vi.fn()) {
|
|
|
25
25
|
describe("ListControlsSheet band filters", () => {
|
|
26
26
|
it("counts an active band and lets its chip clear it", () => {
|
|
27
27
|
const update = renderBand();
|
|
28
|
-
const trigger = screen.
|
|
29
|
-
expect(trigger.
|
|
28
|
+
const trigger = screen.getByRole("button", { name: /^Filters \(/ });
|
|
29
|
+
expect(trigger.getAttribute("data-mp-action")).toBe("filter");
|
|
30
|
+
expect(trigger.textContent).toBe("");
|
|
31
|
+
expect(document.querySelector('[data-badge="count"]')?.textContent).toContain("1");
|
|
30
32
|
fireEvent.click(screen.getByRole("button", { name: "Generation I" }));
|
|
31
33
|
expect(update).toHaveBeenCalledWith({ bands: { generation: "" } });
|
|
32
34
|
});
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import {
|
|
2
|
-
Badge,
|
|
3
2
|
Chip,
|
|
4
3
|
SheetModal,
|
|
5
4
|
Text,
|
|
@@ -14,7 +13,6 @@ import {
|
|
|
14
13
|
DiceFiveIcon,
|
|
15
14
|
HeartIcon,
|
|
16
15
|
ListIcon,
|
|
17
|
-
SlidersHorizontalIcon,
|
|
18
16
|
SquaresFourIcon,
|
|
19
17
|
TableIcon,
|
|
20
18
|
} from "@phosphor-icons/react";
|
|
@@ -23,6 +21,7 @@ import { useEffect, useState } from "react";
|
|
|
23
21
|
import { useTranslation } from "react-i18next";
|
|
24
22
|
import { BandFilter } from "./BandFilters";
|
|
25
23
|
import { activeFilterCount, type FrappeListViewMode } from "./listState";
|
|
24
|
+
import { ListFiltersButton } from "./ListFiltersButton";
|
|
26
25
|
import type { ListControlsProps } from "./ListControls";
|
|
27
26
|
import { RangeFilter, rangeCeiling } from "./RangeFilter";
|
|
28
27
|
|
|
@@ -164,15 +163,12 @@ export function ListControlsSheet({
|
|
|
164
163
|
</YStack>
|
|
165
164
|
) : null}
|
|
166
165
|
{hasSheet ? (
|
|
167
|
-
<
|
|
166
|
+
<ListFiltersButton
|
|
167
|
+
count={count}
|
|
168
|
+
open={open}
|
|
168
169
|
onPress={() => setOpen(true)}
|
|
169
|
-
icon={SlidersHorizontalIcon}
|
|
170
|
-
iconAfter={count > 0 ? <Badge count={count} color="gray" /> : undefined}
|
|
171
|
-
aria-label={count > 0 ? t("Filters ({{count}})", { count }) : t("Filters", "Filters")}
|
|
172
170
|
testID={`${testID}-filters`}
|
|
173
|
-
|
|
174
|
-
{t("Filters", "Filters")}
|
|
175
|
-
</Button>
|
|
171
|
+
/>
|
|
176
172
|
) : null}
|
|
177
173
|
</XStack>
|
|
178
174
|
{count > 0 || view || shuffle ? (
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { ActionButton, Badge } from "@multiplatform.one/components";
|
|
2
|
+
import { useTranslation } from "react-i18next";
|
|
3
|
+
|
|
4
|
+
// A bare glyph has no frame for the badge to hang from, and its 44pt target's
|
|
5
|
+
// corner sits clear of the funnel, so the badge pulls in to the glyph.
|
|
6
|
+
const bareGlyphBadgeOffset: [number, number] = [-6, -8];
|
|
7
|
+
|
|
8
|
+
export interface ListFiltersButtonProps {
|
|
9
|
+
/** The active filters: the badge and the accessible name carry it. */
|
|
10
|
+
count: number;
|
|
11
|
+
/** Whether the sheet it opens is showing. */
|
|
12
|
+
open: boolean;
|
|
13
|
+
onPress: () => void;
|
|
14
|
+
testID: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** The list row's filter action (LC-260): the funnel glyph wearing the active count. */
|
|
18
|
+
export function ListFiltersButton({ count, open, onPress, testID }: ListFiltersButtonProps) {
|
|
19
|
+
const { t } = useTranslation();
|
|
20
|
+
return (
|
|
21
|
+
<Badge count={count} offset={bareGlyphBadgeOffset}>
|
|
22
|
+
<ActionButton
|
|
23
|
+
kind="filter"
|
|
24
|
+
label={count > 0 ? t("Filters ({{count}})", { count }) : t("Filters")}
|
|
25
|
+
theme={count > 0 ? "accent" : undefined}
|
|
26
|
+
onPress={onPress}
|
|
27
|
+
aria-haspopup="dialog"
|
|
28
|
+
aria-expanded={open}
|
|
29
|
+
testID={testID}
|
|
30
|
+
/>
|
|
31
|
+
</Badge>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import {
|
|
2
|
-
Badge,
|
|
3
2
|
DropdownMenu,
|
|
4
3
|
SheetModal,
|
|
5
4
|
Tooltip,
|
|
@@ -10,10 +9,11 @@ import {
|
|
|
10
9
|
} from "@multiplatform.one/components";
|
|
11
10
|
import { Button, SearchInput, Select } from "@multiplatform.one/forms";
|
|
12
11
|
import { useResolvedKnobs } from "@multiplatform.one/theme";
|
|
13
|
-
import { DiceFiveIcon, DotsThreeIcon
|
|
12
|
+
import { DiceFiveIcon, DotsThreeIcon } from "@phosphor-icons/react";
|
|
14
13
|
import { useState, type ReactNode } from "react";
|
|
15
14
|
import { useTranslation } from "react-i18next";
|
|
16
15
|
import { ListFilterPanel } from "./ListFilterPanel";
|
|
16
|
+
import { ListFiltersButton } from "./ListFiltersButton";
|
|
17
17
|
import { activeFilterCount, type FrappeListControls } from "./listState";
|
|
18
18
|
import type { ListControlsResult } from "./useListControls";
|
|
19
19
|
|
|
@@ -31,7 +31,7 @@ export interface ListSidebarToolbarProps {
|
|
|
31
31
|
shufflePanel?: ReactNode;
|
|
32
32
|
/**
|
|
33
33
|
* The page's secondary actions. Below `md` they leave the header for the
|
|
34
|
-
* ⋯ menu beside the
|
|
34
|
+
* ⋯ menu beside the filter glyph.
|
|
35
35
|
*/
|
|
36
36
|
menuItems: readonly DropdownMenuEntry[];
|
|
37
37
|
testID: string;
|
|
@@ -39,9 +39,11 @@ export interface ListSidebarToolbarProps {
|
|
|
39
39
|
|
|
40
40
|
/**
|
|
41
41
|
* The toolbar of a list whose filters live in the page's sidebar: search
|
|
42
|
-
* and sort from `md` up; below it search, a
|
|
42
|
+
* and sort from `md` up; below it search, a funnel glyph wearing the
|
|
43
43
|
* active-filter count that opens the same filters (sort included) in a
|
|
44
|
-
* bottom sheet, and a ⋯ menu holding the secondary actions.
|
|
44
|
+
* bottom sheet, and a ⋯ menu holding the secondary actions. Both glyphs are
|
|
45
|
+
* LC-176 icon buttons, so the search keeps the row's remaining width and a
|
|
46
|
+
* long placeholder fits a 390pt phone.
|
|
45
47
|
*/
|
|
46
48
|
export function ListSidebarToolbar({
|
|
47
49
|
controls,
|
|
@@ -61,7 +63,6 @@ export function ListSidebarToolbar({
|
|
|
61
63
|
const { state, update } = list;
|
|
62
64
|
const sorts = controls.sorts ?? [];
|
|
63
65
|
const active = activeFilterCount(state);
|
|
64
|
-
const filtersLabel = active > 0 ? t("Filters ({{count}})", { count: active }) : t("Filters");
|
|
65
66
|
|
|
66
67
|
return (
|
|
67
68
|
<YStack {...knobProps.gap} testID={`${testID}-controls`}>
|
|
@@ -126,20 +127,12 @@ export function ListSidebarToolbar({
|
|
|
126
127
|
alignItems="center"
|
|
127
128
|
testID={`${testID}-phone-tools`}
|
|
128
129
|
>
|
|
129
|
-
<
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
aria-label={filtersLabel}
|
|
136
|
-
aria-haspopup="dialog"
|
|
137
|
-
aria-expanded={sheetOpen}
|
|
138
|
-
testID={`${testID}-filters-button`}
|
|
139
|
-
>
|
|
140
|
-
{t("Filters")}
|
|
141
|
-
</Button>
|
|
142
|
-
</Badge>
|
|
130
|
+
<ListFiltersButton
|
|
131
|
+
count={active}
|
|
132
|
+
open={sheetOpen}
|
|
133
|
+
onPress={() => setSheetOpen(true)}
|
|
134
|
+
testID={`${testID}-filters-button`}
|
|
135
|
+
/>
|
|
143
136
|
{menuItems.length > 0 ? (
|
|
144
137
|
<DropdownMenu items={[...menuItems]} placement="bottom-end">
|
|
145
138
|
<Button
|