@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.
Files changed (127) hide show
  1. package/dist/cjs/FrappeForm.cjs +20 -26
  2. package/dist/cjs/FrappeForm.native.js +26 -26
  3. package/dist/cjs/FrappeForm.native.js.map +1 -1
  4. package/dist/cjs/FrappeListView.cjs +26 -12
  5. package/dist/cjs/FrappeListView.native.js +41 -12
  6. package/dist/cjs/FrappeListView.native.js.map +1 -1
  7. package/dist/cjs/fields/Password/index.cjs +125 -36
  8. package/dist/cjs/fields/Password/index.native.js +101 -13
  9. package/dist/cjs/fields/Password/index.native.js.map +1 -1
  10. package/dist/cjs/list/ListControlsSheet.cjs +5 -11
  11. package/dist/cjs/list/ListControlsSheet.native.js +5 -11
  12. package/dist/cjs/list/ListControlsSheet.native.js.map +1 -1
  13. package/dist/cjs/list/ListFiltersButton.cjs +56 -0
  14. package/dist/cjs/list/ListFiltersButton.native.js +60 -0
  15. package/dist/cjs/list/ListFiltersButton.native.js.map +1 -0
  16. package/dist/cjs/list/ListSidebarToolbar.cjs +5 -17
  17. package/dist/cjs/list/ListSidebarToolbar.native.js +7 -19
  18. package/dist/cjs/list/ListSidebarToolbar.native.js.map +1 -1
  19. package/dist/cjs/list/ListToolsSheet.cjs +5 -18
  20. package/dist/cjs/list/ListToolsSheet.native.js +7 -20
  21. package/dist/cjs/list/ListToolsSheet.native.js.map +1 -1
  22. package/dist/cjs/list/defaultProjection.cjs +56 -0
  23. package/dist/cjs/list/defaultProjection.native.js +107 -0
  24. package/dist/cjs/list/defaultProjection.native.js.map +1 -0
  25. package/dist/cjs/shared/devWarn.native.js.map +1 -1
  26. package/dist/esm/FrappeForm.mjs +21 -27
  27. package/dist/esm/FrappeForm.mjs.map +1 -1
  28. package/dist/esm/FrappeForm.native.js +27 -27
  29. package/dist/esm/FrappeForm.native.js.map +1 -1
  30. package/dist/esm/FrappeListView.mjs +26 -12
  31. package/dist/esm/FrappeListView.mjs.map +1 -1
  32. package/dist/esm/FrappeListView.native.js +41 -12
  33. package/dist/esm/FrappeListView.native.js.map +1 -1
  34. package/dist/esm/fields/Password/index.mjs +125 -36
  35. package/dist/esm/fields/Password/index.mjs.map +1 -1
  36. package/dist/esm/fields/Password/index.native.js +101 -13
  37. package/dist/esm/fields/Password/index.native.js.map +1 -1
  38. package/dist/esm/list/ListControlsSheet.mjs +7 -13
  39. package/dist/esm/list/ListControlsSheet.mjs.map +1 -1
  40. package/dist/esm/list/ListControlsSheet.native.js +7 -13
  41. package/dist/esm/list/ListControlsSheet.native.js.map +1 -1
  42. package/dist/esm/list/ListFiltersButton.mjs +31 -0
  43. package/dist/esm/list/ListFiltersButton.mjs.map +1 -0
  44. package/dist/esm/list/ListFiltersButton.native.js +32 -0
  45. package/dist/esm/list/ListFiltersButton.native.js.map +1 -0
  46. package/dist/esm/list/ListSidebarToolbar.mjs +7 -19
  47. package/dist/esm/list/ListSidebarToolbar.mjs.map +1 -1
  48. package/dist/esm/list/ListSidebarToolbar.native.js +9 -21
  49. package/dist/esm/list/ListSidebarToolbar.native.js.map +1 -1
  50. package/dist/esm/list/ListToolsSheet.mjs +7 -20
  51. package/dist/esm/list/ListToolsSheet.mjs.map +1 -1
  52. package/dist/esm/list/ListToolsSheet.native.js +9 -22
  53. package/dist/esm/list/ListToolsSheet.native.js.map +1 -1
  54. package/dist/esm/list/defaultProjection.mjs +29 -0
  55. package/dist/esm/list/defaultProjection.mjs.map +1 -0
  56. package/dist/esm/list/defaultProjection.native.js +77 -0
  57. package/dist/esm/list/defaultProjection.native.js.map +1 -0
  58. package/dist/esm/shared/devWarn.mjs.map +1 -1
  59. package/dist/esm/shared/devWarn.native.js.map +1 -1
  60. package/dist/jsx/FrappeForm.mjs +21 -27
  61. package/dist/jsx/FrappeForm.mjs.map +1 -1
  62. package/dist/jsx/FrappeForm.native.js +26 -26
  63. package/dist/jsx/FrappeForm.native.js.map +1 -1
  64. package/dist/jsx/FrappeListView.mjs +26 -12
  65. package/dist/jsx/FrappeListView.mjs.map +1 -1
  66. package/dist/jsx/FrappeListView.native.js +41 -12
  67. package/dist/jsx/FrappeListView.native.js.map +1 -1
  68. package/dist/jsx/fields/Password/index.mjs +125 -36
  69. package/dist/jsx/fields/Password/index.mjs.map +1 -1
  70. package/dist/jsx/fields/Password/index.native.js +101 -13
  71. package/dist/jsx/fields/Password/index.native.js.map +1 -1
  72. package/dist/jsx/list/ListControlsSheet.mjs +7 -13
  73. package/dist/jsx/list/ListControlsSheet.mjs.map +1 -1
  74. package/dist/jsx/list/ListControlsSheet.native.js +5 -11
  75. package/dist/jsx/list/ListControlsSheet.native.js.map +1 -1
  76. package/dist/jsx/list/ListFiltersButton.mjs +31 -0
  77. package/dist/jsx/list/ListFiltersButton.mjs.map +1 -0
  78. package/dist/jsx/list/ListFiltersButton.native.js +60 -0
  79. package/dist/jsx/list/ListFiltersButton.native.js.map +1 -0
  80. package/dist/jsx/list/ListSidebarToolbar.mjs +7 -19
  81. package/dist/jsx/list/ListSidebarToolbar.mjs.map +1 -1
  82. package/dist/jsx/list/ListSidebarToolbar.native.js +7 -19
  83. package/dist/jsx/list/ListSidebarToolbar.native.js.map +1 -1
  84. package/dist/jsx/list/ListToolsSheet.mjs +7 -20
  85. package/dist/jsx/list/ListToolsSheet.mjs.map +1 -1
  86. package/dist/jsx/list/ListToolsSheet.native.js +7 -20
  87. package/dist/jsx/list/ListToolsSheet.native.js.map +1 -1
  88. package/dist/jsx/list/defaultProjection.mjs +29 -0
  89. package/dist/jsx/list/defaultProjection.mjs.map +1 -0
  90. package/dist/jsx/list/defaultProjection.native.js +107 -0
  91. package/dist/jsx/list/defaultProjection.native.js.map +1 -0
  92. package/dist/jsx/shared/devWarn.mjs.map +1 -1
  93. package/dist/jsx/shared/devWarn.native.js.map +1 -1
  94. package/package.json +16 -16
  95. package/src/FrappeForm.recordChanged.spec.tsx +179 -0
  96. package/src/FrappeForm.recordChanged.stories.tsx +83 -0
  97. package/src/FrappeForm.successIsCallback.spec.tsx +154 -0
  98. package/src/FrappeForm.tsx +34 -33
  99. package/src/FrappeListView.projection.spec.tsx +193 -0
  100. package/src/FrappeListView.sidebar.spec.tsx +20 -2
  101. package/src/FrappeListView.successIsCallback.spec.tsx +143 -0
  102. package/src/FrappeListView.tsx +63 -25
  103. package/src/fields/Password/Password.secret.spec.tsx +194 -0
  104. package/src/fields/Password/Password.spec.tsx +15 -8
  105. package/src/fields/Password/index.tsx +134 -35
  106. package/src/list/ListControls.spec.tsx +10 -1
  107. package/src/list/ListControlsSheet.spec.tsx +4 -2
  108. package/src/list/ListControlsSheet.tsx +5 -9
  109. package/src/list/ListFiltersButton.tsx +33 -0
  110. package/src/list/ListSidebarToolbar.tsx +13 -20
  111. package/src/list/ListToolsSheet.tsx +10 -18
  112. package/src/list/defaultProjection.ts +66 -0
  113. package/src/shared/devWarn.ts +2 -1
  114. package/types/FrappeForm.d.ts.map +1 -1
  115. package/types/FrappeListView.d.ts.map +1 -1
  116. package/types/fields/Password/index.d.ts +6 -0
  117. package/types/fields/Password/index.d.ts.map +1 -1
  118. package/types/list/ListControlsSheet.d.ts.map +1 -1
  119. package/types/list/ListFiltersButton.d.ts +11 -0
  120. package/types/list/ListFiltersButton.d.ts.map +1 -0
  121. package/types/list/ListSidebarToolbar.d.ts +5 -3
  122. package/types/list/ListSidebarToolbar.d.ts.map +1 -1
  123. package/types/list/ListToolsSheet.d.ts.map +1 -1
  124. package/types/list/defaultProjection.d.ts +26 -0
  125. package/types/list/defaultProjection.d.ts.map +1 -0
  126. package/types/shared/devWarn.d.ts +1 -1
  127. 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" value="hunter2" />);
17
- const input = result.container.querySelector("input") as HTMLInputElement;
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" value="hunter2" />);
38
- const input = result.container.querySelector("input") as HTMLInputElement;
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" value="hunter2" />);
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("hunter2");
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.querySelector("input") as HTMLInputElement;
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" value="secret" size={token} />);
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 { useProps } from "tamagui";
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
- const { revealed, toggle: toggleReveal } = usePasswordReveal({ inputRef, id, text });
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
- <FormsInput.Box {...boxContract} disabled={disabled} paddingInlineEnd={0}>
156
- <FormsInput.Area
157
- ref={inputRef}
158
- {...(areaInputProps as object)}
159
- id={id}
160
- value={inputValue ?? ""}
161
- disabled={disabled}
162
- readOnly={fieldProps.readOnly}
163
- placeholder={placeholder}
164
- type={revealed ? "text" : "password"}
165
- secureTextEntry={!revealed}
166
- textContentType="password"
167
- autoComplete="current-password"
168
- spellCheck={false}
169
- aria-required={fieldProps.required || undefined}
170
- aria-invalid={hasError || undefined}
171
- aria-readonly={fieldProps.readOnly || undefined}
172
- onChangeText={(next: string) => {
173
- handleChange?.(next);
174
- handleChangeText(next);
175
- }}
176
- />
177
- <PasswordReveal
178
- revealed={revealed}
179
- onToggle={toggleReveal}
180
- controls={id}
181
- disabled={disabled}
182
- size={resolvedSize}
183
- />
184
- </FormsInput.Box>
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("badges the Filters button with the active narrowings", () => {
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.getByTestId("roster-filters");
29
- expect(trigger.querySelector('[data-badge="count"]')?.textContent).toContain("1");
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
- <Button
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, FadersHorizontalIcon } from "@phosphor-icons/react";
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 Filters button.
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 Filters button wearing the
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
- <Badge count={active}>
130
- <Button
131
- outlined={active === 0}
132
- theme={active > 0 ? "accent" : undefined}
133
- icon={<FadersHorizontalIcon size={16} />}
134
- onPress={() => setSheetOpen(true)}
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