@structured-id/ui-core 4.0.0 → 4.2.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.
@@ -2,15 +2,22 @@ import { describe, it, expect } from "vitest";
2
2
  import { mount } from "@vue/test-utils";
3
3
  import { defineComponent } from "vue";
4
4
  import SidPrincipalInput from "./SidPrincipalInput.vue";
5
- import { REQUIRED_FIELD_TEXT } from "../utils/validators";
5
+ import { principalRecognizable } from "./principalInputTexts";
6
6
 
7
7
  const stubs = {
8
8
  "q-input": defineComponent({
9
9
  name: "QInputStub",
10
- props: ["modelValue", "rules", "lazyRules"],
10
+ props: [
11
+ "modelValue",
12
+ "rules",
13
+ "lazyRules",
14
+ "label",
15
+ "error",
16
+ "errorMessage",
17
+ ],
11
18
  template: "<div><slot name='prepend' /><slot name='append' /></div>",
12
19
  }),
13
- "q-icon": { template: "<span />" },
20
+ "q-icon": { props: ["name"], template: "<span :data-icon='name' />" },
14
21
  "q-chip": { template: "<span><slot /></span>" },
15
22
  };
16
23
 
@@ -31,19 +38,233 @@ describe("SidPrincipalInput", () => {
31
38
  expect(input({ lazyRules: false }).props("lazyRules")).toBe(false);
32
39
  });
33
40
 
34
- // One text for an empty required field, the same the password field uses.
35
- it("says the shared text of an empty required field", () => {
36
- const [required] = input().props("rules") as Rule[];
37
- expect(required("")).toBe(REQUIRED_FIELD_TEXT);
38
- expect(required("alice")).toBe(true);
39
- const [custom] = input({ requiredText: "Enter it" }).props(
41
+ // Until the input is long enough to be a principal of some type nothing
42
+ // is shown as wrong, an empty field included: the form keeps its button
43
+ // disabled instead.
44
+ it("shows nothing wrong before a type is recognized", () => {
45
+ const [type] = input().props("rules") as Rule[];
46
+ for (const value of ["", "a", "al"]) {
47
+ expect(type(value), JSON.stringify(value)).toBe(true);
48
+ }
49
+ });
50
+
51
+ // A field that accepts only some principal types refuses the others before
52
+ // any call, naming what it accepts: a registration that takes email only
53
+ // must not let a username reach the server.
54
+ it("refuses a principal type it does not accept", () => {
55
+ const [type] = input({ types: ["email"] }).props("rules") as Rule[];
56
+ expect(type("alice_wonder")).toBe("Enter an email address");
57
+ expect(type("+380501234567")).toBe("Enter an email address");
58
+ expect(type("alice@sid.example.com")).toBe(true);
59
+ const [two] = input({ types: ["email", "username"] }).props(
60
+ "rules",
61
+ ) as Rule[];
62
+ expect(two("+380501234567")).toBe("Enter an email address or a username");
63
+ expect(two("alice_wonder")).toBe(true);
64
+ });
65
+
66
+ // Regression: a username shorter than the minimum was not recognized as a
67
+ // username and the field asked for "email, phone, or username" instead of
68
+ // saying the minimum.
69
+ it("tells a too-short username the minimum length", () => {
70
+ const [type] = input({ minUsernameLength: 6 }).props("rules") as Rule[];
71
+ expect(type("sid")).toBe("A username has at least 6 characters");
72
+ expect(type("sidsid")).toBe(true);
73
+ const [login] = input().props("rules") as Rule[];
74
+ expect(login("sid")).toBe(true);
75
+ });
76
+
77
+ // One or two characters are nobody yet: no type is recognized from them,
78
+ // the field stays a login field with the neutral icon, and nothing is
79
+ // shown as wrong (the form only keeps its button disabled).
80
+ it("recognizes no type from fewer than three characters", () => {
81
+ for (const short of ["a", "al", "+3", "a@", "ЩЩ", " a "]) {
82
+ const field = mount(SidPrincipalInput, {
83
+ props: { modelValue: short, types: ["email", "username"] },
84
+ global: { stubs },
85
+ });
86
+ const stub = field.findComponent({ name: "QInputStub" });
87
+ expect(stub.props("label"), short).toBe("Login");
88
+ expect(field.html(), short).toContain('data-icon="sym_o_login"');
89
+ const [type] = stub.props("rules") as Rule[];
90
+ expect(type(short), short).toBe(true);
91
+ }
92
+ expect(principalRecognizable("al")).toBe(false);
93
+ expect(principalRecognizable(" al ")).toBe(false);
94
+ expect(principalRecognizable("ali")).toBe(true);
95
+ expect(principalRecognizable("Щук")).toBe(true);
96
+ });
97
+
98
+ // Regression: a username with a letter outside a-z was taken for no type
99
+ // and refused before the server could apply its repertoire; its length is
100
+ // counted in code points of its NFC form, as the server counts it.
101
+ it("takes a username in any script and counts its code points", () => {
102
+ const [type] = input({
103
+ types: ["email", "username"],
104
+ minUsernameLength: 6,
105
+ }).props("rules") as Rule[];
106
+ expect(type("Иван_Петров")).toBe(true);
107
+ expect(type("Щукин")).toBe("A username has at least 6 characters");
108
+ expect(type("Jürgen")).toBe(true); // 7 code points, 6 in NFC
109
+ // Whitespace, inside or around, is refused, not trimmed into a name, and
110
+ // the refusal says what is wrong.
111
+ for (const spaced of ["Иван Петров", " Иван_Петров", "Иван_Петров "]) {
112
+ expect(type(spaced)).toBe("A username cannot contain spaces");
113
+ }
114
+ });
115
+
116
+ // Regression: a username with a character it cannot hold (a dot, a dash,
117
+ // a symbol) was taken for no type at all and told to "enter an email
118
+ // address or a username", which names no problem. Input without `@` and
119
+ // `+` is a username attempt, and the refusal names the characters allowed.
120
+ it("tells a username which characters it can hold", () => {
121
+ const [two] = input({ types: ["email", "username"] }).props(
40
122
  "rules",
41
123
  ) as Rule[];
42
- expect(custom("")).toBe("Enter it");
124
+ for (const odd of ["ali.ce", "ali-ce", "😀😀😀", "alice#acme.corp"]) {
125
+ expect(two(odd)).toBe(
126
+ "A username can contain only letters, digits and underscores",
127
+ );
128
+ }
129
+ // A field that takes no username still asks for what it takes.
130
+ const [email] = input({ types: ["email"] }).props("rules") as Rule[];
131
+ expect(email("ali.ce")).toBe("Enter an email address");
132
+ expect(
133
+ input({ types: ["email", "username"], modelValue: "ali.ce" }).props(
134
+ "label",
135
+ ),
136
+ ).toBe("Username");
137
+ });
138
+
139
+ // Regression: the label already names the recognized type, and a chip
140
+ // repeated it beside the icon. The chip is shown only when the form's own
141
+ // label does not name the type.
142
+ it("names the type once", () => {
143
+ const named = mount(SidPrincipalInput, {
144
+ props: { modelValue: "alice@sid.example.com" },
145
+ global: { stubs },
146
+ });
147
+ expect(named.findComponent({ name: "QInputStub" }).props("label")).toBe(
148
+ "Email",
149
+ );
150
+ expect(named.text()).not.toContain("email");
151
+ const own = mount(SidPrincipalInput, {
152
+ props: { modelValue: "alice@sid.example.com", label: "Sign in with" },
153
+ global: { stubs },
154
+ });
155
+ expect(own.text()).toContain("email");
156
+ });
157
+
158
+ // Regression: a phone number on a field that takes no phone got the phone
159
+ // icon and chip, as if it were accepted. A type the field refuses shows
160
+ // the neutral login icon and no chip.
161
+ it("does not dress a refused type as accepted", () => {
162
+ const w = mount(SidPrincipalInput, {
163
+ props: {
164
+ modelValue: "+380501234567",
165
+ types: ["email", "username"],
166
+ label: "Sign in with",
167
+ },
168
+ global: { stubs },
169
+ });
170
+ expect(w.html()).toContain('data-icon="sym_o_login"');
171
+ expect(w.text()).not.toContain("phone");
172
+ });
173
+
174
+ // Regression: an email-only field showed the generic login icon until an
175
+ // address was typed, beside its "Email" label. A field of one type shows
176
+ // that type's icon from the start, greyed until the input is recognized.
177
+ it("shows a single-type field's icon from the start", () => {
178
+ const empty = mount(SidPrincipalInput, {
179
+ props: { modelValue: "", types: ["email"] },
180
+ global: { stubs },
181
+ });
182
+ expect(empty.html()).toContain('data-icon="sym_o_mail"');
183
+ expect(
184
+ mount(SidPrincipalInput, {
185
+ props: { modelValue: "", types: ["email", "username"] },
186
+ global: { stubs },
187
+ }).html(),
188
+ ).toContain('data-icon="sym_o_login"');
189
+ });
190
+
191
+ // The label names the accepted types unless the form gives its own.
192
+ // A field of one type is named by it. A field of several is a login
193
+ // field until the input shows which type it is, then it names that type;
194
+ // a recognized type the field does not accept leaves it a login field.
195
+ it("labels the field by the types it accepts", () => {
196
+ expect(input({ types: ["email"] }).props("label")).toBe("Email");
197
+ expect(input({ types: ["username"] }).props("label")).toBe("Username");
198
+ expect(input().props("label")).toBe("Login");
199
+ const two = { types: ["email", "username"] };
200
+ expect(input(two).props("label")).toBe("Login");
201
+ expect(
202
+ input({ ...two, modelValue: "alice@sid.example.com" }).props("label"),
203
+ ).toBe("Email");
204
+ expect(input({ ...two, modelValue: "alice_wonder" }).props("label")).toBe(
205
+ "Username",
206
+ );
207
+ expect(input({ ...two, modelValue: "+380501234567" }).props("label")).toBe(
208
+ "Login",
209
+ );
210
+ expect(input({ ...two, texts: { login: "Логин" } }).props("label")).toBe(
211
+ "Логин",
212
+ );
213
+ expect(
214
+ input({ types: ["email"], label: "Your email" }).props("label"),
215
+ ).toBe("Your email");
216
+ // An explicitly empty label is the form's choice of no label.
217
+ expect(
218
+ input({ types: ["email", "username"], label: "" }).props("label"),
219
+ ).toBe("");
220
+ });
221
+
222
+ // The chip naming the detected type is a text like the others: a
223
+ // translated form does not switch back to English when a type is found.
224
+ it("takes the type chip's text from the texts", () => {
225
+ const w = mount(SidPrincipalInput, {
226
+ props: {
227
+ modelValue: "alice@sid.example.com",
228
+ label: "Sign in with",
229
+ texts: { typeChip: (t: string) => `chip:${t}` },
230
+ },
231
+ global: { stubs },
232
+ });
233
+ expect(w.text()).toContain("chip:email");
234
+ });
235
+
236
+ // The form disables its submit on this, before any error is shown: only a
237
+ // recognized, accepted, well-formed value, not refused by the server, is
238
+ // valid.
239
+ it("tells the form whether the value can be submitted", () => {
240
+ const valid = (props: { modelValue: string; fieldError?: string }) => {
241
+ const w = mount(SidPrincipalInput, {
242
+ props: { types: ["email", "username"], ...props },
243
+ global: { stubs },
244
+ });
245
+ return w.emitted("update:valid")?.at(-1)?.[0];
246
+ };
247
+ expect(valid({ modelValue: "" })).toBe(false);
248
+ expect(valid({ modelValue: "al" })).toBe(false);
249
+ expect(valid({ modelValue: "ali" })).toBe(true);
250
+ expect(valid({ modelValue: "ali.ce" })).toBe(false);
251
+ expect(valid({ modelValue: "+380501234567" })).toBe(false);
252
+ expect(valid({ modelValue: "alice@" })).toBe(false);
253
+ expect(valid({ modelValue: "alice@sid.example.com" })).toBe(true);
254
+ expect(
255
+ valid({ modelValue: "alice@sid.example.com", fieldError: "Taken" }),
256
+ ).toBe(false);
43
257
  });
44
258
 
45
- it("does not require an optional field", () => {
46
- const [required] = input({ required: false }).props("rules") as Rule[];
47
- expect(required("")).toBe(true);
259
+ // A refusal the server bound to this field is shown on it.
260
+ it("shows an error given from outside", () => {
261
+ const shown = input({
262
+ fieldError: "Registration here takes an email address",
263
+ });
264
+ expect(shown.props("error")).toBe(true);
265
+ expect(shown.props("errorMessage")).toBe(
266
+ "Registration here takes an email address",
267
+ );
268
+ expect(input().props("error")).toBe(false);
48
269
  });
49
270
  });
@@ -2,8 +2,10 @@
2
2
  <q-input
3
3
  ref="inputRef"
4
4
  v-model="model"
5
- :label="label"
6
- :rules="[requiredRule, typeRule]"
5
+ :label="fieldLabel"
6
+ :rules="[typeRule]"
7
+ :error="!!fieldError"
8
+ :error-message="fieldError ?? undefined"
7
9
  outlined
8
10
  type="text"
9
11
  :inputmode="inputMode"
@@ -16,7 +18,7 @@
16
18
  <template #prepend>
17
19
  <q-icon :name="iconName" :color="iconColor" size="24px" />
18
20
  </template>
19
- <template v-if="model && detectedType !== 'unknown'" #append>
21
+ <template v-if="showChip" #append>
20
22
  <q-chip
21
23
  dense
22
24
  :color="chipColor"
@@ -33,17 +35,34 @@
33
35
  <script setup lang="ts">
34
36
  import { ref, computed, watch } from "vue";
35
37
  import { isValidPhoneNumber } from "libphonenumber-js";
36
- import { REQUIRED_FIELD_TEXT, isValidEmail } from "../utils/validators";
38
+ import { isValidEmail } from "../utils/validators";
37
39
 
38
- export type PrincipalType = "email" | "phone" | "username" | "unknown";
40
+ import {
41
+ ALL_PRINCIPAL_TYPES,
42
+ DEFAULT_PRINCIPAL_INPUT_TEXTS,
43
+ principalRecognizable,
44
+ type AcceptedPrincipalType,
45
+ type PrincipalInputTexts,
46
+ type PrincipalType,
47
+ } from "./principalInputTexts";
48
+
49
+ export type { PrincipalType };
39
50
 
40
51
  interface Props {
41
52
  modelValue: string;
53
+ /**
54
+ * The principal types this field accepts; the others are refused before
55
+ * any call, with a text naming the accepted ones. The instance's settings
56
+ * or the scenario decide it (a registration may take email only).
57
+ */
58
+ types?: AcceptedPrincipalType[];
42
59
  minUsernameLength?: number;
43
- required?: boolean;
60
+ /** The label; without one the field names the accepted types. */
44
61
  label?: string;
45
- /** What an empty required field says. */
46
- requiredText?: string;
62
+ /** A refusal the server bound to this field, shown on it until edited. */
63
+ fieldError?: string | null;
64
+ /** Replacements for the field's texts. */
65
+ texts?: Partial<PrincipalInputTexts>;
47
66
  /**
48
67
  * Validate once the field is left, then on every change (true), or on
49
68
  * every change from the first keystroke (false). Default true: a field
@@ -54,17 +73,45 @@ interface Props {
54
73
 
55
74
  const props = withDefaults(defineProps<Props>(), {
56
75
  modelValue: "",
57
- // Username rule: ^[a-z0-9_]{3,32}$ (display form ^[a-zA-Z0-9_]{3,32}$).
76
+ types: () => [...ALL_PRINCIPAL_TYPES],
77
+ // Code points of the NFC display form; the server's minimum decides.
58
78
  minUsernameLength: 3,
59
- required: true,
60
- label: "Email, phone, or username",
61
- requiredText: REQUIRED_FIELD_TEXT,
79
+ label: undefined,
80
+ fieldError: null,
81
+ texts: () => ({}),
62
82
  lazyRules: true,
63
83
  });
64
84
 
85
+ const text = computed<PrincipalInputTexts>(() => ({
86
+ ...DEFAULT_PRINCIPAL_INPUT_TEXTS,
87
+ ...props.texts,
88
+ }));
89
+
90
+ /** The accepted types in their fixed order. */
91
+ const accepted = computed(() =>
92
+ ALL_PRINCIPAL_TYPES.filter((t) => props.types.includes(t)),
93
+ );
94
+
95
+ /**
96
+ * A field of one type is named by it; a field of several is a login field
97
+ * until its input is recognized as one of the accepted types.
98
+ */
99
+ const fieldLabel = computed(() => {
100
+ // An empty label is the form's own choice; only an absent one is derived.
101
+ if (props.label != null) return props.label;
102
+ const [only, ...others] = accepted.value;
103
+ if (only && others.length === 0) return text.value.label([only]);
104
+ const type = detectedType.value;
105
+ return type !== "unknown" && accepted.value.includes(type)
106
+ ? text.value.label([type])
107
+ : text.value.login;
108
+ });
109
+
65
110
  const emit = defineEmits<{
66
111
  "update:modelValue": [value: string];
67
112
  "update:principalType": [type: PrincipalType];
113
+ /** Whether the value can be submitted: a recognized, accepted, well-formed principal. */
114
+ "update:valid": [valid: boolean];
68
115
  }>();
69
116
 
70
117
  const inputRef = ref<{ focus: () => void } | null>(null);
@@ -76,10 +123,13 @@ const model = computed({
76
123
 
77
124
  const USERNAME_MAX_LENGTH = 32;
78
125
 
126
+ /** What a username may hold: letters of any script, marks, digits, `_`. */
127
+ const USERNAME_SHAPE = /^[\p{L}\p{M}\p{N}_]+$/u;
128
+
79
129
  function detectType(value: string): PrincipalType {
80
- if (!value) return "unknown";
130
+ // One or two characters are nobody yet: no type is read from them.
131
+ if (!principalRecognizable(value)) return "unknown";
81
132
  const trimmed = value.trim();
82
- if (!trimmed) return "unknown";
83
133
 
84
134
  // Phone: starts with +
85
135
  if (trimmed.startsWith("+")) return "phone";
@@ -87,17 +137,9 @@ function detectType(value: string): PrincipalType {
87
137
  // Email: contains @ (checked after phone to exclude edge cases)
88
138
  if (trimmed.includes("@")) return "email";
89
139
 
90
- // Username: ^[a-z0-9_]{3,32}$, no leading/trailing _, no __
91
- // Detection is relaxed: accepts uppercase (will normalize), checks min length
92
- const re = new RegExp(
93
- `^[a-zA-Z0-9_]{${props.minUsernameLength},${USERNAME_MAX_LENGTH}}$`,
94
- );
95
- if (re.test(trimmed) && !trimmed.startsWith("_") && !trimmed.endsWith("_")) {
96
- return "username";
97
- }
98
-
99
- // Still typing — not enough chars to determine
100
- return "unknown";
140
+ // Anything else is a username attempt, whatever it holds, so that what is
141
+ // wrong with it (spaces, a dot, a symbol, its length) can be said.
142
+ return "username";
101
143
  }
102
144
 
103
145
  const detectedType = computed(() => detectType(model.value));
@@ -113,39 +155,81 @@ function onInput(value: string | number | null) {
113
155
 
114
156
  // --- Validation rules ---
115
157
 
116
- const requiredRule = (val: string) =>
117
- !props.required || !!val || props.requiredText;
118
-
158
+ /**
159
+ * Until a type is recognized nothing is wrong yet, an empty field included:
160
+ * the form keeps its button disabled instead (see principalRecognizable).
161
+ */
119
162
  const typeRule = (val: string): string | true => {
120
- if (!val) return true; // required rule handles empty
121
163
  const type = detectType(val);
164
+ if (type === "unknown") return true;
165
+ if (!accepted.value.includes(type)) {
166
+ return text.value.enterAccepted(accepted.value);
167
+ }
122
168
 
123
169
  switch (type) {
124
170
  case "email":
125
- return isValidEmail(val.trim()) || "Invalid email address";
171
+ return isValidEmail(val.trim()) || text.value.invalidEmail;
126
172
  case "phone":
127
- return isValidPhoneNumber(val) || "Invalid phone number";
173
+ return isValidPhoneNumber(val) || text.value.invalidPhone;
128
174
  case "username": {
129
- const lower = val.toLowerCase();
130
- const re = new RegExp(
131
- `^[a-z0-9_]{${props.minUsernameLength},${USERNAME_MAX_LENGTH}}$`,
132
- );
133
- if (!re.test(lower)) return "Invalid username";
134
- if (lower.startsWith("_") || lower.endsWith("_"))
135
- return "Username cannot start or end with underscore";
136
- if (lower.includes("__"))
137
- return "Username cannot contain consecutive underscores";
175
+ const name = val;
176
+ if (/\s/u.test(name)) return text.value.usernameSpaces;
177
+ if (!USERNAME_SHAPE.test(name)) return text.value.usernameCharacters;
178
+ // Counted in code points of the NFC form, as the server counts the
179
+ // display; the value itself is sent unchanged.
180
+ const length = [...name.normalize("NFC")].length;
181
+ if (length < props.minUsernameLength)
182
+ return text.value.usernameTooShort(props.minUsernameLength);
183
+ if (length > USERNAME_MAX_LENGTH)
184
+ return text.value.usernameTooLong(USERNAME_MAX_LENGTH);
185
+ if (name.startsWith("_") || name.endsWith("_"))
186
+ return text.value.usernameUnderscoreEdge;
187
+ if (name.includes("__")) return text.value.usernameDoubleUnderscore;
138
188
  return true;
139
189
  }
140
- default:
141
- return "Enter email, phone (+...), or username";
142
190
  }
143
191
  };
144
192
 
193
+ /**
194
+ * A form keeps its submit disabled until this holds, silently: the error a
195
+ * rule names appears only once the field is left, the button never waits for
196
+ * it. A refusal the server bound to the field holds until the value changes.
197
+ */
198
+ const valid = computed(
199
+ () =>
200
+ detectedType.value !== "unknown" &&
201
+ typeRule(model.value) === true &&
202
+ !props.fieldError,
203
+ );
204
+
205
+ watch(valid, (v) => emit("update:valid", v), { immediate: true });
206
+
145
207
  // --- Dynamic icon & type ---
146
208
 
209
+ /** The recognized type when the field accepts it; refused types look neutral. */
210
+ const shownType = computed<PrincipalType>(() => {
211
+ const type = detectedType.value;
212
+ return type !== "unknown" && accepted.value.includes(type) ? type : "unknown";
213
+ });
214
+
215
+ /**
216
+ * The chip names the type only when the label does not: a derived label
217
+ * already says "Email" or "Username".
218
+ */
219
+ const showChip = computed(
220
+ () => props.label != null && shownType.value !== "unknown",
221
+ );
222
+
223
+ /** A field of one type shows that type's icon from the start, greyed until recognized. */
224
+ const iconType = computed<PrincipalType>(() => {
225
+ const [only, ...others] = accepted.value;
226
+ return shownType.value === "unknown" && only && others.length === 0
227
+ ? only
228
+ : shownType.value;
229
+ });
230
+
147
231
  const iconName = computed(() => {
148
- switch (detectedType.value) {
232
+ switch (iconType.value) {
149
233
  case "email":
150
234
  return "sym_o_mail";
151
235
  case "phone":
@@ -158,7 +242,7 @@ const iconName = computed(() => {
158
242
  });
159
243
 
160
244
  const iconColor = computed(() =>
161
- detectedType.value === "unknown" ? "grey-6" : "primary",
245
+ shownType.value === "unknown" ? "grey-6" : "primary",
162
246
  );
163
247
 
164
248
  // inputmode controls mobile keyboard WITHOUT triggering browser autofill.
@@ -177,21 +261,12 @@ const inputMode = computed(() => {
177
261
  }
178
262
  });
179
263
 
180
- const chipLabel = computed(() => {
181
- switch (detectedType.value) {
182
- case "email":
183
- return "email";
184
- case "phone":
185
- return "phone";
186
- case "username":
187
- return "username";
188
- default:
189
- return "";
190
- }
191
- });
264
+ const chipLabel = computed(() =>
265
+ shownType.value === "unknown" ? "" : text.value.typeChip(shownType.value),
266
+ );
192
267
 
193
268
  const chipColor = computed(() => {
194
- switch (detectedType.value) {
269
+ switch (shownType.value) {
195
270
  case "email":
196
271
  return "deep-orange";
197
272
  case "phone":
@@ -209,5 +284,5 @@ function focus() {
209
284
  inputRef.value?.focus();
210
285
  }
211
286
 
212
- defineExpose({ focus, detectedType });
287
+ defineExpose({ focus, detectedType, valid });
213
288
  </script>
@@ -5,6 +5,16 @@
5
5
  * Import as: import { SidPrincipalInput } from '@structured-id/ui-core/quasar'
6
6
  */
7
7
  export { default as SidPrincipalInput } from "./SidPrincipalInput.vue";
8
- export type { PrincipalType } from "./SidPrincipalInput.vue";
8
+ export type {
9
+ AcceptedPrincipalType,
10
+ PrincipalInputTexts,
11
+ PrincipalType,
12
+ } from "./principalInputTexts";
13
+ export {
14
+ ALL_PRINCIPAL_TYPES,
15
+ DEFAULT_PRINCIPAL_INPUT_TEXTS,
16
+ MIN_PRINCIPAL_LENGTH,
17
+ principalRecognizable,
18
+ } from "./principalInputTexts";
9
19
  export { default as SidPasswordCeremony } from "./SidPasswordCeremony.vue";
10
20
  export type { CeremonyOutcome } from "./SidPasswordCeremony.vue";
@@ -0,0 +1,93 @@
1
+ /** The principal types and texts of `SidPrincipalInput`. */
2
+
3
+ export type PrincipalType = "email" | "phone" | "username" | "unknown";
4
+
5
+ /** A principal type a field can accept. */
6
+ export type AcceptedPrincipalType = Exclude<PrincipalType, "unknown">;
7
+
8
+ /** Every type, in the order the label and the texts name them. */
9
+ export const ALL_PRINCIPAL_TYPES: readonly AcceptedPrincipalType[] = [
10
+ "email",
11
+ "phone",
12
+ "username",
13
+ ];
14
+
15
+ /**
16
+ * Fewer characters than this are nobody yet: no type is recognized from them
17
+ * and no form can submit them. Counted in code points of the NFC form,
18
+ * surrounding whitespace aside.
19
+ */
20
+ export const MIN_PRINCIPAL_LENGTH = 3;
21
+
22
+ /** Whether `value` is long enough to be anybody. */
23
+ export function principalRecognizable(value: string): boolean {
24
+ return [...value.trim().normalize("NFC")].length >= MIN_PRINCIPAL_LENGTH;
25
+ }
26
+
27
+ /** What the field says; each text can be replaced (translated) by the form. */
28
+ export interface PrincipalInputTexts {
29
+ /**
30
+ * The label naming one type: the field's only accepted type, or the type
31
+ * the input of a several-type field was recognized as.
32
+ */
33
+ label: (types: AcceptedPrincipalType[]) => string;
34
+ /** The label of a field of several types before its input shows which. */
35
+ login: string;
36
+ /** A value of a type the field does not accept, or of no type at all. */
37
+ enterAccepted: (types: AcceptedPrincipalType[]) => string;
38
+ /** The chip naming the type the value was recognized as. */
39
+ typeChip: (type: AcceptedPrincipalType) => string;
40
+ /** The login form's default subtitle for the accepted types. */
41
+ loginSubtitle: (types: AcceptedPrincipalType[]) => string;
42
+ invalidEmail: string;
43
+ invalidPhone: string;
44
+ usernameTooShort: (min: number) => string;
45
+ usernameTooLong: (max: number) => string;
46
+ usernameUnderscoreEdge: string;
47
+ usernameDoubleUnderscore: string;
48
+ /** A username with whitespace in or around it. */
49
+ usernameSpaces: string;
50
+ /** A username with a character other than a letter, digit or `_`. */
51
+ usernameCharacters: string;
52
+ }
53
+
54
+ /** `a`, `a or b`, `a, b, or c`. */
55
+ function list(words: string[]): string {
56
+ if (words.length <= 1) return words.join("");
57
+ if (words.length === 2) return `${words[0]} or ${words[1]}`;
58
+ return `${words.slice(0, -1).join(", ")}, or ${words[words.length - 1]}`;
59
+ }
60
+
61
+ const LABEL_WORDS: Record<AcceptedPrincipalType, string> = {
62
+ email: "email",
63
+ phone: "phone",
64
+ username: "username",
65
+ };
66
+
67
+ const ENTER_WORDS: Record<AcceptedPrincipalType, string> = {
68
+ email: "an email address",
69
+ phone: "a phone number (+...)",
70
+ username: "a username",
71
+ };
72
+
73
+ export const DEFAULT_PRINCIPAL_INPUT_TEXTS: PrincipalInputTexts = {
74
+ label: (types) => {
75
+ const text = list(types.map((t) => LABEL_WORDS[t]));
76
+ return text.charAt(0).toUpperCase() + text.slice(1);
77
+ },
78
+ login: "Login",
79
+ enterAccepted: (types) => `Enter ${list(types.map((t) => ENTER_WORDS[t]))}`,
80
+ typeChip: (type) => LABEL_WORDS[type],
81
+ loginSubtitle: (types) =>
82
+ `Enter your ${list(types.map((t) => LABEL_WORDS[t]))} to continue`,
83
+ invalidEmail: "Invalid email address",
84
+ invalidPhone: "Invalid phone number",
85
+ usernameTooShort: (min) => `A username has at least ${min} characters`,
86
+ usernameTooLong: (max) => `A username has at most ${max} characters`,
87
+ usernameUnderscoreEdge: "A username cannot start or end with an underscore",
88
+ usernameDoubleUnderscore:
89
+ "A username cannot contain two underscores in a row",
90
+ usernameSpaces: "A username cannot contain spaces",
91
+ usernameCharacters:
92
+ "A username can contain only letters, digits and underscores",
93
+ };