@zeno-lib/forms 0.3.0 → 0.4.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 (40) hide show
  1. package/dist/create-zeno-form.mjs +5 -3
  2. package/dist/index.d.mts +2 -2
  3. package/dist/index.mjs +3 -3
  4. package/dist/lib/empty-value.d.mts +12 -0
  5. package/dist/lib/empty-value.mjs +68 -0
  6. package/dist/lib/schema-defaults.d.mts +9 -1
  7. package/dist/lib/schema-defaults.mjs +33 -1
  8. package/dist/lib/schema-required.d.mts +2 -1
  9. package/dist/lib/schema-required.mjs +49 -21
  10. package/dist/lib/submit-action.mjs +1 -1
  11. package/dist/lib/use-is-invalid.d.mts +2 -1
  12. package/dist/lib/use-is-invalid.mjs +5 -2
  13. package/dist/lib/validation-modes.d.mts +5 -1
  14. package/dist/lib/validation-modes.mjs +9 -4
  15. package/package.json +1 -1
  16. package/src/create-zeno-form.tsx +13 -5
  17. package/src/fields/checkbox-field.tsx +8 -1
  18. package/src/fields/combobox-field.tsx +29 -12
  19. package/src/fields/date-picker-field.tsx +37 -9
  20. package/src/fields/email-field.tsx +8 -0
  21. package/src/fields/input-field.tsx +6 -2
  22. package/src/fields/money-field.tsx +3 -1
  23. package/src/fields/multi-select-field.tsx +8 -1
  24. package/src/fields/number-field.tsx +9 -7
  25. package/src/fields/otp-field.tsx +8 -1
  26. package/src/fields/percentage-field.tsx +3 -1
  27. package/src/fields/radio-group-field.tsx +11 -4
  28. package/src/fields/select-field.tsx +26 -3
  29. package/src/fields/switch-field.tsx +8 -1
  30. package/src/fields/textarea-field.tsx +8 -1
  31. package/src/fields/year-field.tsx +3 -1
  32. package/src/index.ts +1 -0
  33. package/src/lib/empty-value.test.ts +72 -0
  34. package/src/lib/empty-value.ts +130 -0
  35. package/src/lib/schema-defaults.ts +50 -1
  36. package/src/lib/schema-required.test.ts +24 -2
  37. package/src/lib/schema-required.ts +81 -43
  38. package/src/lib/use-is-invalid.ts +14 -1
  39. package/src/lib/validation-modes.ts +18 -6
  40. package/src/use-app-fields.test.tsx +312 -2
@@ -0,0 +1,130 @@
1
+ import type { AnyFieldApi } from "@tanstack/react-form"
2
+
3
+ import { toFieldName } from "./action-result"
4
+ import { extractZodDefaultAt } from "./schema-defaults"
5
+ import { type StandardSchemaLike, validateSync } from "./schema-required"
6
+ import { getFormSchema } from "./validation-modes"
7
+
8
+ // A field's empty value is the first one its schema accepts of `null`,
9
+ // `undefined` and the type's own empty value (`""` for a string, `[]` for an
10
+ // array, through `extractZodDefaults`). So `.nullable()` empties to `null`,
11
+ // `.optional()` to `undefined`, and a bare `z.string()` to `""`. A field that
12
+ // accepts none of them is required. A cleared field holds its empty value,
13
+ // and so does an untouched one whose schema rejects `""` or `[]`.
14
+
15
+ const NAME_SEGMENT = /[^.[\]]+/g
16
+ const INDEX = /^\d+$/
17
+
18
+ // `members[2].name` (or `members.2.name`) → `["members", 2, "name"]`.
19
+ function toPathKeys(name: string): PropertyKey[] {
20
+ return (name.match(NAME_SEGMENT) ?? []).map((part) =>
21
+ INDEX.test(part) ? Number(part) : part
22
+ )
23
+ }
24
+
25
+ function valueAt(root: unknown, keys: readonly PropertyKey[]): unknown {
26
+ let node = root
27
+ for (const key of keys) {
28
+ if (typeof node !== "object" || node === null) {
29
+ return
30
+ }
31
+ node = (node as Record<PropertyKey, unknown>)[key]
32
+ }
33
+ return node
34
+ }
35
+
36
+ // A copy of `root` with `value` at `keys`, copying only the nodes on the way.
37
+ function withValueAt(
38
+ root: unknown,
39
+ keys: readonly PropertyKey[],
40
+ value: unknown
41
+ ): unknown {
42
+ const [key, ...rest] = keys
43
+ if (key === undefined) {
44
+ return value
45
+ }
46
+ const node = (root ?? (typeof key === "number" ? [] : {})) as Record<
47
+ PropertyKey,
48
+ unknown
49
+ >
50
+ const copy = (Array.isArray(node) ? [...node] : { ...node }) as Record<
51
+ PropertyKey,
52
+ unknown
53
+ >
54
+ copy[key] = withValueAt(node[key], rest, value)
55
+ return copy
56
+ }
57
+
58
+ function emptyValueFor(
59
+ schema: StandardSchemaLike | undefined,
60
+ values: unknown,
61
+ name: string,
62
+ fallback: unknown
63
+ ): unknown {
64
+ if (!schema) {
65
+ return fallback
66
+ }
67
+ const keys = toPathKeys(name)
68
+ const fieldName = toFieldName(keys)
69
+ const typeEmpty = extractZodDefaultAt(
70
+ schema as Parameters<typeof extractZodDefaultAt>[0],
71
+ keys
72
+ )
73
+ const candidates = typeEmpty.ok
74
+ ? [null, undefined, typeEmpty.value]
75
+ : [null, undefined]
76
+ for (const candidate of candidates) {
77
+ const issues = validateSync(schema, withValueAt(values, keys, candidate))
78
+ if (!issues) {
79
+ return fallback
80
+ }
81
+ if (!issues.some((issue) => toFieldName(issue.path) === fieldName)) {
82
+ return candidate
83
+ }
84
+ }
85
+ return fallback
86
+ }
87
+
88
+ function isTypeEmpty(value: unknown): boolean {
89
+ return value === "" || (Array.isArray(value) && value.length === 0)
90
+ }
91
+
92
+ // `extractZodDefaults` starts every string at `""` and every array at `[]`.
93
+ // Where the schema rejects that (`z.uuid().nullable()`,
94
+ // `z.email().optional()`), start the field at its empty value instead, so an
95
+ // untouched optional field passes. A required one keeps `""` or `[]`.
96
+ function withEmptyStartValues(
97
+ schema: StandardSchemaLike,
98
+ defaults: Record<string, unknown>
99
+ ): Record<string, unknown> {
100
+ const issues = validateSync(schema, defaults)
101
+ let result: unknown = defaults
102
+ for (const issue of issues ?? []) {
103
+ const name = toFieldName(issue.path)
104
+ const keys = toPathKeys(name)
105
+ const start = valueAt(defaults, keys)
106
+ if (keys.length === 0 || !isTypeEmpty(start)) {
107
+ continue
108
+ }
109
+ const empty = emptyValueFor(schema, result, name, start)
110
+ if (empty !== start) {
111
+ result = withValueAt(result, keys, empty)
112
+ }
113
+ }
114
+ return result as Record<string, unknown>
115
+ }
116
+
117
+ /**
118
+ * The value `field` takes when the user clears it, by the rule above. Typed as
119
+ * the field's value, since the same schema decides both.
120
+ */
121
+ function getEmptyValue<T>(field: AnyFieldApi, fallback: T): T {
122
+ return emptyValueFor(
123
+ getFormSchema(field.form),
124
+ field.form.state.values,
125
+ field.name,
126
+ fallback
127
+ ) as T
128
+ }
129
+
130
+ export { emptyValueFor, getEmptyValue, withEmptyStartValues }
@@ -24,6 +24,7 @@ type SchemaDef = {
24
24
  readonly innerType?: SchemaNode
25
25
  readonly shape?: Record<string, SchemaNode>
26
26
  readonly in?: SchemaNode
27
+ readonly element?: SchemaNode
27
28
  readonly defaultValue?: unknown
28
29
  }
29
30
 
@@ -91,6 +92,54 @@ function extractZodDefaults(schema: SchemaNode): Record<string, unknown> {
91
92
  }
92
93
  }
93
94
 
95
+ // Step from an object or array schema into its `key` child, through any
96
+ // wrapper around it. `null` when there is none.
97
+ function childAt(node: SchemaNode, key: PropertyKey): SchemaNode | null {
98
+ let current: SchemaNode | undefined = node
99
+ for (let depth = 0; depth < MAX_WRAPPER_DEPTH; depth += 1) {
100
+ const def: SchemaDef | undefined = getDef(current)
101
+ switch (def?.type) {
102
+ case "object":
103
+ return (typeof key === "string" && def.shape?.[key]) || null
104
+ case "array":
105
+ return (typeof key === "number" && def.element) || null
106
+ case "default":
107
+ case "nonoptional":
108
+ case "nullable":
109
+ case "optional":
110
+ case "prefault":
111
+ case "readonly":
112
+ current = def.innerType
113
+ continue
114
+ case "pipe":
115
+ current = def.in
116
+ continue
117
+ default:
118
+ return null
119
+ }
120
+ }
121
+ return null
122
+ }
123
+
124
+ // The value `extractZodDefaults` starts the field at `keys` at, array rows
125
+ // included (`["members", 2, "name"]`). `{ ok: false }` when it gives none.
126
+ function extractZodDefaultAt(
127
+ schema: SchemaNode,
128
+ keys: readonly PropertyKey[]
129
+ ): ExtractResult {
130
+ try {
131
+ let node: SchemaNode | null = schema
132
+ for (const key of keys) {
133
+ node = node && childAt(node, key)
134
+ }
135
+ return node && keys.length > 0
136
+ ? extractDefaultForField(node)
137
+ : { ok: false }
138
+ } catch {
139
+ return { ok: false }
140
+ }
141
+ }
142
+
94
143
  function isPlainObject(value: unknown): value is Record<string, unknown> {
95
144
  return (
96
145
  typeof value === "object" &&
@@ -124,4 +173,4 @@ function deepMergeDefaults(
124
173
  return result
125
174
  }
126
175
 
127
- export { deepMergeDefaults, extractZodDefaults }
176
+ export { deepMergeDefaults, extractZodDefaultAt, extractZodDefaults }
@@ -58,6 +58,22 @@ describe("getRequiredPaths", () => {
58
58
  expect(required.has("members[0].name")).toBe(true)
59
59
  })
60
60
 
61
+ test("a field that accepts null is not required", () => {
62
+ const schema = z.object({
63
+ count: z.number(),
64
+ day: z.date().nullable(),
65
+ kind: z.enum(["a", "b"]).nullable(),
66
+ ownerId: z.uuid().nullable(),
67
+ total: z.number().nullable(),
68
+ })
69
+ const required = getRequiredPaths(schema)
70
+ expect(required.has("count")).toBe(true)
71
+ expect(required.has("day")).toBe(false)
72
+ expect(required.has("kind")).toBe(false)
73
+ expect(required.has("ownerId")).toBe(false)
74
+ expect(required.has("total")).toBe(false)
75
+ })
76
+
61
77
  test("nested z.object reports the parent and its required children", () => {
62
78
  const schema = z.object({
63
79
  profile: z.object({
@@ -80,20 +96,26 @@ describe("getRequiredPaths", () => {
80
96
 
81
97
  test("array rows are reported with TanStack bracket syntax", () => {
82
98
  const schema = z.object({
99
+ emails: z.array(z.email()),
83
100
  members: z.array(
84
101
  z.object({
85
102
  name: z.string().min(1),
86
103
  note: z.string().optional(),
87
104
  })
88
105
  ),
106
+ picked: z.array(z.string()).min(1),
89
107
  tags: z.array(z.string()),
90
108
  })
91
109
  const required = getRequiredPaths(schema)
92
- expect(required.has("members")).toBe(true)
93
110
  expect(required.has("members[0].name")).toBe(true)
94
111
  expect(required.has("members.0.name")).toBe(false)
95
112
  expect(required.has("members[0].note")).toBe(false)
96
- expect(required.has("tags[0]")).toBe(true)
113
+ expect(required.has("emails[0]")).toBe(true)
114
+ expect(required.has("picked")).toBe(true)
115
+ // `[]` and `""` are what the form starts them at, and both pass.
116
+ expect(required.has("members")).toBe(false)
117
+ expect(required.has("tags")).toBe(false)
118
+ expect(required.has("tags[0]")).toBe(false)
97
119
  })
98
120
 
99
121
  test("nested arrays descend through every level", () => {
@@ -9,14 +9,17 @@
9
9
  // A missing parent hides its children (the schema stops at the missing key), so
10
10
  // the probe descends: when an issue says it expected an object or an array
11
11
  // (Zod and Valibot both put that on `issue.expected`), the next pass fills that
12
- // path with `{}` / `[{}]` and validates again. Array rows are probed through
13
- // index `0` and recorded index-agnostically, so `members[0].name` in the set
14
- // matches every row's `members[3].name` via `toRequiredPathKey`.
12
+ // path and validates again. An object gets `{}`. An array gets one missing row
13
+ // (`[undefined]`), so the following pass probes the row's own schema. Rows are
14
+ // probed through index `0` and recorded index-agnostically, so
15
+ // `members[0].name` in the set matches every row's `members[3].name` via
16
+ // `toRequiredPathKey`.
15
17
  //
16
- // A missing string is probed again as `""`, the value the form starts it at
17
- // (`extractZodDefaults`). A field that accepts `""` (a bare `z.string()`)
18
- // can't fail while untouched, so it leaves the set; one that rejects it
19
- // (`.min(1)`, `z.email()`) stays.
18
+ // A missing field is then probed with an empty value: `""` for a string, then
19
+ // `null`; `[]` for an array; `null` for anything else. A field that accepts one
20
+ // (a bare `z.string()` or `z.array()`, anything `.nullable()`) leaves the set,
21
+ // matching `lib/empty-value.ts`; one that rejects them all (`.min(1)`,
22
+ // `z.email()`, a bare `z.number()`) stays.
20
23
  //
21
24
  // Paths use TanStack Form's field-name syntax: dots for object keys, brackets
22
25
  // for array indices (`members[0].name`, not `members.0.name`).
@@ -47,7 +50,8 @@ type StandardSchemaLike = {
47
50
  }
48
51
  }
49
52
 
50
- const MAX_PROBE_DEPTH = 8
53
+ // Each level can take two passes: the empty value, then the descent.
54
+ const MAX_PROBE_DEPTH = 16
51
55
 
52
56
  const INDEX_SEGMENT = /^\d+$/
53
57
 
@@ -92,26 +96,25 @@ function toRequiredPathKey(name: string): string {
92
96
  }
93
97
 
94
98
  function probeValue(expected: unknown): unknown {
95
- if (typeof expected !== "string") {
96
- return
97
- }
98
- switch (expected.toLowerCase()) {
99
+ switch (typeof expected === "string" ? expected.toLowerCase() : undefined) {
99
100
  case "object":
100
101
  return {}
101
102
  case "array":
102
- return [{}]
103
+ return []
103
104
  case "string":
104
105
  return ""
105
106
  default:
106
- return
107
+ return null
107
108
  }
108
109
  }
109
110
 
110
- // Write `value` at `keys` inside `root`, only where nothing is set yet.
111
+ // Write `value` at `keys` inside `root`, only where nothing is set yet unless
112
+ // `replace` is set.
111
113
  function fillAt(
112
114
  root: Record<PropertyKey, unknown>,
113
115
  keys: readonly PropertyKey[],
114
- value: unknown
116
+ value: unknown,
117
+ replace = false
115
118
  ): boolean {
116
119
  let node: Record<PropertyKey, unknown> = root
117
120
  for (const key of keys.slice(0, -1)) {
@@ -122,7 +125,7 @@ function fillAt(
122
125
  node = next as Record<PropertyKey, unknown>
123
126
  }
124
127
  const last = keys.at(-1) as PropertyKey
125
- if (node[last] !== undefined) {
128
+ if (!replace && node[last] !== undefined) {
126
129
  return false
127
130
  }
128
131
  node[last] = value
@@ -145,22 +148,27 @@ function validateSync(
145
148
  return "issues" in result && result.issues ? result.issues : []
146
149
  }
147
150
 
151
+ type ProbeFill = {
152
+ readonly keys: readonly PropertyKey[]
153
+ readonly path: string
154
+ readonly value: unknown
155
+ }
156
+
148
157
  type ProbePass = {
149
- readonly descended: boolean
150
158
  readonly failing: ReadonlySet<string>
151
- readonly filledStrings: readonly string[]
159
+ readonly filled: readonly ProbeFill[]
152
160
  }
153
161
 
154
162
  // Record one pass's failing paths, and fill the probe at each of them for the
155
- // next pass.
163
+ // next pass. A path whose empty value already passed stays out of the set.
156
164
  function recordPass(
157
165
  issues: readonly StandardIssue[],
158
166
  probe: Record<PropertyKey, unknown>,
159
- required: Set<string>
167
+ required: Set<string>,
168
+ cleared: ReadonlySet<string>
160
169
  ): ProbePass {
161
170
  const failing = new Set<string>()
162
- const filledStrings: string[] = []
163
- let descended = false
171
+ const filled: ProbeFill[] = []
164
172
  for (const issue of issues) {
165
173
  const keys = issue.path?.map(pathKey) ?? []
166
174
  if (keys.length === 0) {
@@ -168,40 +176,70 @@ function recordPass(
168
176
  }
169
177
  const path = joinPath(keys)
170
178
  failing.add(path)
171
- required.add(path)
179
+ if (!cleared.has(path)) {
180
+ required.add(path)
181
+ }
172
182
  const value = probeValue(issue.expected)
173
183
  if (value !== undefined && fillAt(probe, keys, value)) {
184
+ filled.push({ keys, path, value })
185
+ }
186
+ }
187
+ return { failing, filled }
188
+ }
189
+
190
+ function isEmptyProbe(value: unknown): boolean {
191
+ return value === "" || value === null || Array.isArray(value)
192
+ }
193
+
194
+ // Settle what the previous pass filled with an empty value: a path that now
195
+ // passes leaves the required set. A string that rejects `""` gets `null` next,
196
+ // and an array checked empty gets one missing row, so the row is probed.
197
+ function settleEmptied(
198
+ emptied: readonly ProbeFill[],
199
+ failing: ReadonlySet<string>,
200
+ probe: Record<PropertyKey, unknown>,
201
+ required: Set<string>,
202
+ cleared: Set<string>
203
+ ): { readonly next: ProbeFill[]; readonly descended: boolean } {
204
+ const next: ProbeFill[] = []
205
+ let descended = false
206
+ for (const fill of emptied) {
207
+ const passed = !failing.has(fill.path)
208
+ if (passed) {
209
+ required.delete(fill.path)
210
+ cleared.add(fill.path)
211
+ }
212
+ if (Array.isArray(fill.value)) {
213
+ fill.value.push(undefined)
214
+ descended = true
215
+ } else if (!passed && fill.value === "") {
216
+ fillAt(probe, fill.keys, null, true)
217
+ next.push({ ...fill, value: null })
174
218
  descended = true
175
- if (value === "") {
176
- filledStrings.push(path)
177
- }
178
219
  }
179
220
  }
180
- return { descended, failing, filledStrings }
221
+ return { descended, next }
181
222
  }
182
223
 
183
224
  function getRequiredPaths(schema: StandardSchemaLike): Set<string> {
184
225
  const required = new Set<string>()
226
+ const cleared = new Set<string>()
185
227
  const probe: Record<PropertyKey, unknown> = {}
186
- // Paths the previous pass filled with `""`: they stay only if they still fail.
187
- let filledStrings: readonly string[] = []
228
+ // What the previous pass filled with an empty value: each stays required
229
+ // only if it still fails.
230
+ let emptied: readonly ProbeFill[] = []
188
231
  for (let depth = 0; depth < MAX_PROBE_DEPTH; depth += 1) {
189
232
  const issues = validateSync(schema, probe)
190
233
  if (!issues) {
191
234
  return required
192
235
  }
193
- const {
194
- descended,
195
- failing,
196
- filledStrings: filled,
197
- } = recordPass(issues, probe, required)
198
- for (const path of filledStrings) {
199
- if (!failing.has(path)) {
200
- required.delete(path)
201
- }
202
- }
203
- filledStrings = filled
204
- if (!descended) {
236
+ const { failing, filled } = recordPass(issues, probe, required, cleared)
237
+ const settled = settleEmptied(emptied, failing, probe, required, cleared)
238
+ emptied = [
239
+ ...filled.filter(({ value }) => isEmptyProbe(value)),
240
+ ...settled.next,
241
+ ]
242
+ if (filled.length === 0 && !settled.descended) {
205
243
  break
206
244
  }
207
245
  }
@@ -209,4 +247,4 @@ function getRequiredPaths(schema: StandardSchemaLike): Set<string> {
209
247
  }
210
248
 
211
249
  export type { StandardSchemaLike }
212
- export { getRequiredPaths, toRequiredPathKey }
250
+ export { getRequiredPaths, toRequiredPathKey, validateSync }
@@ -6,6 +6,7 @@ import {
6
6
  getFormHideFieldErrors,
7
7
  getFormValidationMode,
8
8
  isFieldRequired,
9
+ isFieldRequiredBySchema,
9
10
  type ValidationMode,
10
11
  } from "./validation-modes"
11
12
 
@@ -106,4 +107,16 @@ function useIsFieldRequired(field: AnyFieldApi): boolean {
106
107
  return isFieldRequired(field.form, field.name)
107
108
  }
108
109
 
109
- export { useHideFieldErrors, useIsFieldRequired, useIsInvalid }
110
+ // Returns whether the form's schema requires this field, even with
111
+ // `requiredIndicator: false`. Drives what a required field does (no clear
112
+ // button, `aria-required`), where `useIsFieldRequired` drives the `*`.
113
+ function useIsFieldRequiredBySchema(field: AnyFieldApi): boolean {
114
+ return isFieldRequiredBySchema(field.form, field.name)
115
+ }
116
+
117
+ export {
118
+ useHideFieldErrors,
119
+ useIsFieldRequired,
120
+ useIsFieldRequiredBySchema,
121
+ useIsInvalid,
122
+ }
@@ -2,7 +2,7 @@
2
2
 
3
3
  import type { AnyFormApi } from "@tanstack/react-form"
4
4
 
5
- import { toRequiredPathKey } from "./schema-required"
5
+ import { type StandardSchemaLike, toRequiredPathKey } from "./schema-required"
6
6
 
7
7
  type ValidationMode = "change" | "blur" | "submit" | "blur-then-change"
8
8
 
@@ -13,6 +13,7 @@ type ZenoFormState = {
13
13
  hideFieldErrors: boolean
14
14
  requiredIndicator: boolean
15
15
  requiredFields: ReadonlySet<string>
16
+ schema: StandardSchemaLike | undefined
16
17
  }
17
18
 
18
19
  const EMPTY_REQUIRED_FIELDS: ReadonlySet<string> = new Set<string>()
@@ -21,6 +22,7 @@ const DEFAULT_FORM_STATE: ZenoFormState = {
21
22
  hideFieldErrors: false,
22
23
  requiredFields: EMPTY_REQUIRED_FIELDS,
23
24
  requiredIndicator: true,
25
+ schema: undefined,
24
26
  validation: DEFAULT_VALIDATION_MODE,
25
27
  }
26
28
 
@@ -57,19 +59,29 @@ function getFormHideFieldErrors(form: AnyFormApi): boolean {
57
59
  return getFormZenoState(form).hideFieldErrors
58
60
  }
59
61
 
62
+ // Whether the schema requires the field, whatever `requiredIndicator` says.
63
+ function getFormSchema(form: AnyFormApi): StandardSchemaLike | undefined {
64
+ return getFormZenoState(form).schema
65
+ }
66
+
67
+ function isFieldRequiredBySchema(form: AnyFormApi, name: string): boolean {
68
+ return getFormZenoState(form).requiredFields.has(toRequiredPathKey(name))
69
+ }
70
+
60
71
  function isFieldRequired(form: AnyFormApi, name: string): boolean {
61
- const state = getFormZenoState(form)
62
- if (!state.requiredIndicator) {
63
- return false
64
- }
65
- return state.requiredFields.has(toRequiredPathKey(name))
72
+ return (
73
+ getFormZenoState(form).requiredIndicator &&
74
+ isFieldRequiredBySchema(form, name)
75
+ )
66
76
  }
67
77
 
68
78
  export {
69
79
  DEFAULT_VALIDATION_MODE,
70
80
  getFormHideFieldErrors,
81
+ getFormSchema,
71
82
  getFormValidationMode,
72
83
  isFieldRequired,
84
+ isFieldRequiredBySchema,
73
85
  setFormZenoState,
74
86
  type ValidationMode,
75
87
  }