@jskit-ai/users-web 0.2.9 → 0.2.11

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.
@@ -0,0 +1,85 @@
1
+ import {
2
+ normalizeObject,
3
+ normalizeOneOf
4
+ } from "@jskit-ai/kernel/shared/support/normalize";
5
+
6
+ // Default copy, by copy prop key; the copy prop overrides it.
7
+ const DEFAULT_COPY_KEYS = Object.freeze({
8
+ title: "jskit.users.accountSettings.profile.title",
9
+ previewSizePrefix: "jskit.users.accountSettings.profile.previewSizePrefix",
10
+ previewSizeSuffix: "jskit.users.accountSettings.profile.previewSizeSuffix",
11
+ replaceAvatar: "jskit.users.accountSettings.profile.replaceAvatar",
12
+ removeAvatar: "jskit.users.accountSettings.profile.removeAvatar",
13
+ selectedFilePrefix: "jskit.users.accountSettings.profile.selectedFilePrefix",
14
+ displayName: "jskit.users.accountSettings.profile.displayName",
15
+ email: "jskit.users.accountSettings.profile.email",
16
+ emailHint: "jskit.users.accountSettings.profile.emailHint",
17
+ saveProfile: "jskit.users.accountSettings.profile.save"
18
+ });
19
+
20
+ function resolveProfileCopy(copy, t) {
21
+ return {
22
+ ...Object.fromEntries(Object.entries(DEFAULT_COPY_KEYS).map(([name, key]) => [name, t(key)])),
23
+ ...normalizeObject(copy)
24
+ };
25
+ }
26
+
27
+ function resolveProfileVariant(value) {
28
+ const variant = normalizeObject(value);
29
+ return {
30
+ layout: normalizeOneOf(variant.layout, ["compact", "comfortable"], "comfortable"),
31
+ surface: normalizeOneOf(variant.surface, ["plain", "carded"], "carded"),
32
+ density: normalizeOneOf(variant.density, ["compact", "comfortable"], "comfortable"),
33
+ tone: normalizeOneOf(variant.tone, ["neutral", "emphasized"], "neutral")
34
+ };
35
+ }
36
+
37
+ function resolveProfileFeatures(value) {
38
+ const features = normalizeObject(value);
39
+ return {
40
+ header: features.header !== false,
41
+ removeAvatar: features.removeAvatar !== false
42
+ };
43
+ }
44
+
45
+ function resolveProfileUiClasses(ui) {
46
+ const classes = normalizeObject(normalizeObject(ui).classes);
47
+ return {
48
+ root: String(classes.root || "").trim(),
49
+ card: String(classes.card || "").trim()
50
+ };
51
+ }
52
+
53
+ function resolveProfileUiTestIds(ui) {
54
+ const testIds = normalizeObject(normalizeObject(ui).testIds);
55
+ return {
56
+ root: String(testIds.root || "profile-client-element"),
57
+ card: String(testIds.card || "profile-client-card"),
58
+ submitButton: String(testIds.submitButton || "profile-submit-button"),
59
+ avatarReplaceButton: String(testIds.avatarReplaceButton || "profile-avatar-replace-button"),
60
+ avatarRemoveButton: String(testIds.avatarRemoveButton || "profile-avatar-remove-button")
61
+ };
62
+ }
63
+
64
+ function resolveProfileRootClasses(variant, uiClasses) {
65
+ const classes = [
66
+ "profile-client-element",
67
+ `profile-client-element--layout-${variant.layout}`,
68
+ `profile-client-element--surface-${variant.surface}`,
69
+ `profile-client-element--density-${variant.density}`,
70
+ `profile-client-element--tone-${variant.tone}`
71
+ ];
72
+ if (uiClasses.root) {
73
+ classes.push(uiClasses.root);
74
+ }
75
+ return classes;
76
+ }
77
+
78
+ export {
79
+ resolveProfileCopy,
80
+ resolveProfileFeatures,
81
+ resolveProfileRootClasses,
82
+ resolveProfileUiClasses,
83
+ resolveProfileUiTestIds,
84
+ resolveProfileVariant
85
+ };
@@ -12,18 +12,20 @@ function normalizeThemePreference(value) {
12
12
  return THEME_PREFERENCE_SYSTEM;
13
13
  }
14
14
 
15
- function resolveSystemThemeName({ prefersDark } = {}) {
16
- if (typeof prefersDark === "boolean") {
17
- return prefersDark ? THEME_PREFERENCE_DARK : THEME_PREFERENCE_LIGHT;
18
- }
15
+ /** The page's window; null outside a browser. */
16
+ function pageWindow() {
17
+ return typeof window === "undefined" ? null : window;
18
+ }
19
19
 
20
- if (typeof window !== "undefined" && typeof window.matchMedia === "function") {
21
- return window.matchMedia("(prefers-color-scheme: dark)").matches
22
- ? THEME_PREFERENCE_DARK
23
- : THEME_PREFERENCE_LIGHT;
24
- }
20
+ /** Whether the system asks for a dark theme; false where it cannot say. */
21
+ function systemPrefersDark() {
22
+ const page = pageWindow();
23
+ // readable-check: allow method-probe - platform object: matchMedia exists only in a browser
24
+ return typeof page?.matchMedia === "function" && page.matchMedia("(prefers-color-scheme: dark)").matches;
25
+ }
25
26
 
26
- return THEME_PREFERENCE_LIGHT;
27
+ function resolveSystemThemeName({ prefersDark = systemPrefersDark() } = {}) {
28
+ return prefersDark ? THEME_PREFERENCE_DARK : THEME_PREFERENCE_LIGHT;
27
29
  }
28
30
 
29
31
  function resolveThemeNameForPreference(themePreference, options = {}) {
@@ -44,12 +46,8 @@ function resolveThemePreferenceStorage(options = {}) {
44
46
  return customStorage;
45
47
  }
46
48
 
47
- if (typeof window !== "object" || !window) {
48
- return null;
49
- }
50
-
51
49
  try {
52
- return window.localStorage;
50
+ return pageWindow()?.localStorage || null;
53
51
  } catch {
54
52
  return null;
55
53
  }
@@ -57,7 +55,7 @@ function resolveThemePreferenceStorage(options = {}) {
57
55
 
58
56
  function readPersistedThemePreference(options = {}) {
59
57
  const storage = resolveThemePreferenceStorage(options);
60
- if (!storage || typeof storage.getItem !== "function") {
58
+ if (!storage) {
61
59
  return THEME_PREFERENCE_SYSTEM;
62
60
  }
63
61
 
@@ -70,7 +68,7 @@ function readPersistedThemePreference(options = {}) {
70
68
 
71
69
  function persistThemePreference(themePreference, options = {}) {
72
70
  const storage = resolveThemePreferenceStorage(options);
73
- if (!storage || typeof storage.setItem !== "function") {
71
+ if (!storage) {
74
72
  return false;
75
73
  }
76
74
 
@@ -108,38 +106,14 @@ function persistBootstrapThemePreference(payload = {}, options = {}) {
108
106
  }
109
107
 
110
108
  function resolveVuetifyThemeController(vueApp) {
111
- if (!vueApp || typeof vueApp !== "object") {
112
- return null;
113
- }
114
-
115
- const provides = vueApp._context?.provides;
116
- if (!provides || typeof provides !== "object") {
117
- return null;
118
- }
119
-
120
- const themeController = provides[ThemeSymbol];
121
- if (
122
- !themeController ||
123
- typeof themeController !== "object" ||
124
- typeof themeController.change !== "function" ||
125
- !themeController.name
126
- ) {
127
- return null;
128
- }
129
-
130
- return themeController;
109
+ return vueApp._context.provides[ThemeSymbol] || null;
131
110
  }
132
111
 
112
+ /**
113
+ * @param {import("vuetify").ThemeInstance} themeController
114
+ * @param {string} themeName
115
+ */
133
116
  function setVuetifyThemeName(themeController, themeName) {
134
- if (
135
- !themeController ||
136
- typeof themeController !== "object" ||
137
- typeof themeController.change !== "function" ||
138
- !themeController.name
139
- ) {
140
- return false;
141
- }
142
-
143
117
  const normalizedThemeName = themeName === THEME_PREFERENCE_DARK ? THEME_PREFERENCE_DARK : THEME_PREFERENCE_LIGHT;
144
118
  if (themeController.name.value === normalizedThemeName) {
145
119
  return false;
@@ -9,6 +9,12 @@ const UsersWebClientProvider = defineProvider({
9
9
  components: "client.components",
10
10
  shell: "client.shell"
11
11
  },
12
+ /**
13
+ * @param {{
14
+ * components: import("@jskit-ai/kernel/client/types").ClientComponents,
15
+ * shell: import("../types.js").UsersShell
16
+ * }} capabilities
17
+ */
12
18
  setup({ components, shell }) {
13
19
  components.register("users.web.home.tools.widget", UsersHomeToolsWidget);
14
20
  components.register("users.web.profile.element", ProfileClientElement);
@@ -0,0 +1,65 @@
1
+ // The values users-web's client hands out and receives across files.
2
+
3
+ /**
4
+ * The part of shell-web's placement runtime the bootstrap handler writes the signed-in user into.
5
+ * @typedef {object} UsersPlacementRuntime
6
+ * @property {() => Record<string, any>} getContext
7
+ * @property {(patch: Record<string, any>, options?: { source?: string }) => void} setContext
8
+ */
9
+
10
+ /**
11
+ * The part of shell-web's placement runtime the account settings sections read.
12
+ * @typedef {object} AccountSectionsPlacementRuntime
13
+ * @property {() => number} getRevision
14
+ * @property {(listener: (event?: { revision?: number }) => void) => () => void} subscribe
15
+ * @property {(query: { surface: string, target: string, context: unknown }) => readonly Record<string, any>[]} getPlacements
16
+ */
17
+
18
+ /**
19
+ * What shell-web's bootstrap hands a payload handler.
20
+ * @typedef {object} UsersBootstrapContext
21
+ * @property {Record<string, any>} [payload]
22
+ * @property {unknown} [error]
23
+ * @property {UsersPlacementRuntime} placementRuntime
24
+ * @property {string} [source]
25
+ */
26
+
27
+ /**
28
+ * The part of the `client.shell` capability users-web uses.
29
+ * @typedef {object} UsersShell
30
+ * @property {{ register: (handler: ReturnType<typeof import("./bootstrap/user-bootstrap-handler.js").createUsersBootstrapUserHandler>) => void }} bootstrapHandlers
31
+ */
32
+
33
+ /**
34
+ * One line of feedback about an account action.
35
+ * @typedef {object} AccountFeedback
36
+ * @property {string} message
37
+ * @property {string} [severity]
38
+ * @property {string} [channel]
39
+ * @property {string} [dedupeKey]
40
+ */
41
+
42
+ /**
43
+ * What `ProfileClientElement` shows and edits.
44
+ * @typedef {object} ProfileElementState
45
+ * @property {{ displayName: string, email: string }} profileForm
46
+ * @property {{ avatarSize: number }} preferencesForm
47
+ * @property {{ effectiveUrl: string, hasUploadedAvatar: boolean }} profileAvatar
48
+ * @property {string} profileInitials
49
+ * @property {string} selectedAvatarFileName
50
+ * @property {{ displayName?: string }} profileFieldErrors
51
+ * @property {{ isPending: import("vue").Ref<boolean> }} profileMutation
52
+ * @property {{ isPending: import("vue").Ref<boolean> }} avatarDeleteMutation
53
+ */
54
+
55
+ /**
56
+ * What `ProfileClientElement` calls.
57
+ * @typedef {object} ProfileElementActions
58
+ * @property {(payload: { displayName: string }) => unknown} submitProfile
59
+ * @property {() => unknown} openAvatarEditor
60
+ * @property {() => unknown} submitAvatarDelete
61
+ */
62
+
63
+ /** @typedef {ReturnType<typeof import("./composables/useAccountSettingsRuntime.js").useAccountSettingsRuntime>} AccountSettingsRuntime */
64
+
65
+ export {};
@@ -78,19 +78,26 @@ test("preference option labels follow the app's vue-i18n locale", () => {
78
78
  test("useAccountLocale follows the bootstrap payload's saved locale", async () => {
79
79
  const handler = createUsersBootstrapUserHandler();
80
80
  const accountLocale = useAccountLocale();
81
+ const placementRuntime = {
82
+ getContext() {
83
+ return {};
84
+ },
85
+ setContext() {}
86
+ };
81
87
 
82
88
  await handler.applyBootstrapPayload({
83
- payload: { session: { authenticated: true, userId: "7" }, userSettings: { locale: "it-it" } }
89
+ payload: { session: { authenticated: true, userId: "7" }, userSettings: { locale: "it-it" } },
90
+ placementRuntime
84
91
  });
85
92
  assert.equal(accountLocale.value, "it-IT");
86
93
 
87
- await handler.handleBootstrapError({ error: { statusCode: 500 } });
94
+ await handler.handleBootstrapError({ error: { statusCode: 500 }, placementRuntime });
88
95
  assert.equal(accountLocale.value, "it-IT");
89
96
 
90
- await handler.handleBootstrapError({ error: { statusCode: 401 } });
97
+ await handler.handleBootstrapError({ error: { statusCode: 401 }, placementRuntime });
91
98
  assert.equal(accountLocale.value, "");
92
99
 
93
- await handler.applyBootstrapPayload({ payload: { session: { authenticated: true }, userSettings: { locale: "en" } } });
94
- await handler.applyBootstrapPayload({ payload: { session: { authenticated: false }, userSettings: null } });
100
+ await handler.applyBootstrapPayload({ payload: { session: { authenticated: true }, userSettings: { locale: "en" } }, placementRuntime });
101
+ await handler.applyBootstrapPayload({ payload: { session: { authenticated: false }, userSettings: null }, placementRuntime });
95
102
  assert.equal(accountLocale.value, "");
96
103
  });