@jskit-ai/users-web 0.2.20 → 0.2.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,11 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ - Account settings sections are pages in Material 3's list-detail layout (shell-web's `ShellListDetail`): `/account` lists the sections and `/account/<section>` opens one, instead of `?section=` on one page. `AccountSettingsClientElement` reads the route's `section` param and links each section to its path, keeping `returnTo`; on compact and medium windows it shows the list or one section, whose Up returns to the list, and on expanded and large windows the list beside the open section (or the first section on the list's own page), where Done replaces Up as the way out. The `users/account-settings` pattern adds `src/pages/account/[section].vue`, with parent `"/"`. Upgrading: add that page beside `src/pages/account/index.vue`, and change links to `/account?section=<value>` into `/account/<value>`.
6
+ - Account settings have one way out: on a page with the shell's Up arrow (one that declares a parent) there is no Done action beside it, which read as a second back control. Done shows only where Up can't go back: a page without a parent, or a `returnTo` on another of the app's origins.
7
+ - Account settings use Material 3's label type and single-column form: the section list's labels are label-large (14px/500) instead of Vuetify's 13px nav title, and `ProfileClientElement`'s and the pattern's display name and email each take the form's full width, so a long email no longer truncates in a half-width column.
8
+ - The profile form's Save button, and the account-settings pattern's Save buttons, keep 16dp from the last field in every section; they touched it, as fields hide their empty details. The pattern's Avatar size select takes the same column width as the fields beside it.
9
+ - The account settings section list marks the selected section with Material 3's pill indicator in `secondary-container`, as the shell's navigation does, instead of a grey rectangle; its hover and pressed state layers are pills too.
5
10
  - The account-settings pattern ships the account surface's layout, `src/pages/account.vue` (`ShellLayout` around `<RouterView />`, `meta.jskit.surface: "account"`), so the settings page's title, Up arrow and Done action show in the top app bar, with no navigation bar, rail or drawer on a surface that has no navigation. An app whose account page renders under no layout copies it; until then `jskit check` reports `nav/missing-shell-layout` on the page.
6
11
  - The account settings page and the account-settings pattern's sections sit directly on the page, without bordered sheets, and the section list has no extra border or tint.
7
12
  - The account settings page has no in-page title or Back button: it gives the shell's top app bar its title (`useShellPageTitle`) and a Done action (`ShellPageAction`) that returns to `returnTo`, another of the app's origins included, as Back did. Message keys: `jskit.users.accountSettings.back` is removed and `jskit.users.accountSettings.done` ("Done") is new. The pattern's section buttons no longer set a fixed height; they are Material 3's 40dp buttons inside material-web's 48dp touch target.
@@ -1,4 +1,5 @@
1
1
  import { createApp, h, reactive, ref, shallowReactive } from "vue";
2
+ import { createPinia } from "pinia";
2
3
  import { createRouter, createWebHistory, RouterView } from "vue-router";
3
4
  import { QueryClient, VueQueryPlugin } from "@tanstack/vue-query";
4
5
  import "vuetify/styles";
@@ -7,8 +8,15 @@ import { createMaterialVuetifyOptions } from "@jskit-ai/material-web/client";
7
8
  import { createVuetify } from "vuetify";
8
9
  import * as components from "vuetify/components";
9
10
  import * as directives from "vuetify/directives";
11
+ import { silentLogger } from "@jskit-ai/kernel/shared/support/silentLogger";
12
+ import ShellLayout from "@jskit-ai/shell-web/client/components/ShellLayout";
10
13
  import { SHELL_WEB_ERROR_RUNTIME_KEY } from "@jskit-ai/shell-web/client/error";
11
14
  import { SHELL_WEB_PLACEMENT_RUNTIME_KEY } from "@jskit-ai/shell-web/client/placement";
15
+ import { createWebPlacementRuntime } from "../../../shell-web/src/client/placement/runtime.js";
16
+ import {
17
+ SHELL_NAVIGATION_RUNTIME_KEY,
18
+ createShellNavigationRuntime
19
+ } from "../../../shell-web/src/client/navigation/shellNavigationRuntime.js";
12
20
  import AccountSettingsClientElement from "../../src/client/components/AccountSettingsClientElement.vue";
13
21
  import ProfileClientElement from "../../src/client/components/ProfileClientElement.vue";
14
22
  import { useAccountLocale } from "../../src/client/lib/accountLocale.js";
@@ -17,12 +25,27 @@ import PreferencesSection from "../../patterns/account-settings/example/src/comp
17
25
  import ProfileSection from "../../patterns/account-settings/example/src/components/account/settings/AccountSettingsProfileSection.vue";
18
26
  import SignInMethodsSection from "../../patterns/account-settings/example/src/components/account/settings/AccountSettingsSignInMethodsSection.vue";
19
27
 
28
+ // The real shell (its layout, placements and navigation) around the real account settings code; the error runtime is
29
+ // a double that records reports. The account surface is laid out as the account-settings pattern lays it out: /account
30
+ // lists the sections and /account/<section> opens one, whose parent is the list.
31
+ const sectionComponents = new Map();
20
32
  function section(value, title, component, order) {
21
- return { order, component, props: { value, title, usesSharedRuntime: true } };
33
+ sectionComponents.set(`fixture.section.${value}`, component);
34
+ return {
35
+ id: `fixture.section.${value}`,
36
+ // The section outlet itself, as the app's topology resolves users-web's sections to it.
37
+ target: "account-settings:sections",
38
+ internal: true,
39
+ kind: "component",
40
+ surfaces: ["account"],
41
+ order,
42
+ componentToken: `fixture.section.${value}`,
43
+ props: { value, title, usesSharedRuntime: true }
44
+ };
22
45
  }
23
46
 
24
- // The shell's two runtimes as doubles; everything below them is the real account settings code.
25
- const context = {
47
+ const placement = createWebPlacementRuntime({ components: sectionComponents, logger: silentLogger });
48
+ placement.setContext({
26
49
  surfaceConfig: {
27
50
  tenancyMode: "none",
28
51
  defaultSurfaceId: "home",
@@ -32,9 +55,7 @@ const context = {
32
55
  account: { id: "account", enabled: true, pagesRoot: "account", routeBase: "/account", requiresWorkspace: false }
33
56
  }
34
57
  }
35
- };
36
- const listeners = new Set();
37
- let revision = 1;
58
+ });
38
59
  let placements = new URLSearchParams(window.location.search).has("empty")
39
60
  ? []
40
61
  : [
@@ -43,16 +64,7 @@ let placements = new URLSearchParams(window.location.search).has("empty")
43
64
  section("notifications", "Notifications", NotificationsSection, 300),
44
65
  section("sign-in-methods", "Sign-in methods", SignInMethodsSection, 400)
45
66
  ];
46
- const placement = {
47
- getContext: () => context,
48
- setContext: () => context,
49
- getPlacements: () => placements,
50
- getRevision: () => revision,
51
- subscribe(listener) {
52
- listeners.add(listener);
53
- return () => listeners.delete(listener);
54
- }
55
- };
67
+ placement.replacePlacements(placements);
56
68
  const errorRuntime = {
57
69
  report(event) {
58
70
  window.accountFixture.reports.push(event);
@@ -93,8 +105,7 @@ window.accountFixture = {
93
105
  profileState,
94
106
  placeSection() {
95
107
  placements = [...placements, section("extra", "Extra", { render: () => h("p", "Extra section") }, 150)];
96
- revision += 1;
97
- listeners.forEach((listener) => listener({ revision }));
108
+ placement.replacePlacements(placements);
98
109
  }
99
110
  };
100
111
 
@@ -126,14 +137,32 @@ const ProfileElementPage = {
126
137
  const router = createRouter({
127
138
  history: createWebHistory(),
128
139
  routes: [
129
- { path: "/home", component: { render: () => h("p", { "data-testid": "home" }, "Home") } },
130
- { path: "/account", component: AccountPage },
140
+ { path: "/home", component: { render: () => h("p", { "data-testid": "home" }, "Home") }, meta: { jskit: { surface: "home" } } },
141
+ {
142
+ path: "/account",
143
+ component: RouterView,
144
+ meta: { jskit: { surface: "account" } },
145
+ children: [
146
+ // src/pages/account/index.vue: the section list, an inner page of home.
147
+ { path: "", component: AccountPage, meta: { jskit: { parent: { surface: "home", path: "/" } } } },
148
+ // src/pages/account/[section].vue: one section, whose parent is the list.
149
+ { path: ":section", component: AccountPage, meta: { jskit: { parent: "/" } } }
150
+ ]
151
+ },
131
152
  { path: "/profile-element", component: ProfileElementPage }
132
153
  ]
133
154
  });
134
- const app = createApp({ render: () => h(components.VApp, null, { default: () => h(components.VMain, null, { default: () => h(RouterView) }) }) });
155
+ const navigation = createShellNavigationRuntime({ router });
156
+ navigation.install();
157
+ const app = createApp({
158
+ render: () => h(components.VApp, null, {
159
+ default: () => h(ShellLayout, { surfaceLabel: "Fixture" }, { default: () => h(RouterView) })
160
+ })
161
+ });
135
162
  app.provide(SHELL_WEB_PLACEMENT_RUNTIME_KEY, placement);
163
+ app.provide(SHELL_NAVIGATION_RUNTIME_KEY, navigation);
136
164
  app.provide(SHELL_WEB_ERROR_RUNTIME_KEY, errorRuntime);
165
+ app.use(createPinia());
137
166
  app.use(router);
138
167
  app.use(VueQueryPlugin, { queryClient: new QueryClient({ defaultOptions: { queries: { retry: false } } }) });
139
168
  app.use(createVuetify({ components, directives, ...createMaterialVuetifyOptions() }));
@@ -143,6 +143,7 @@ const router = createRouter({
143
143
  { path: "/home", component: HomePage },
144
144
  { path: "/errors", component: ErrorsPage },
145
145
  { path: "/account", component: AccountSettingsClientElement },
146
+ { path: "/account/:section", component: AccountSettingsClientElement },
146
147
  { path: "/auth/login", component: DefaultLoginView },
147
148
  { path: "/auth/reset-password", component: DefaultResetPasswordView },
148
149
  { path: "/auth/signout", component: DefaultSignOutView }
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "@jskit-ai/users-web",
3
- "version": "0.2.20",
3
+ "version": "0.2.21",
4
4
  "type": "module",
5
5
  "scripts": {
6
- "test": "node --test --test-concurrency=1"
6
+ "test": "jskit test"
7
7
  },
8
8
  "exports": {
9
9
  "./client": "./src/client/index.js",
@@ -13,15 +13,15 @@
13
13
  "./locales/en.json": "./locales/en.json"
14
14
  },
15
15
  "dependencies": {
16
- "@jskit-ai/uploads-image-web": "0.2.10",
17
- "@jskit-ai/users-core": "0.2.12"
16
+ "@jskit-ai/uploads-image-web": "0.2.11",
17
+ "@jskit-ai/users-core": "0.2.13"
18
18
  },
19
19
  "peerDependencies": {
20
- "@jskit-ai/auth-web": "0.2.15",
21
- "@jskit-ai/http-runtime": "0.2.10",
22
- "@jskit-ai/http-web": "0.2.18",
23
- "@jskit-ai/kernel": "0.2.10",
24
- "@jskit-ai/shell-web": "0.2.13",
20
+ "@jskit-ai/auth-web": "0.2.16",
21
+ "@jskit-ai/http-runtime": "0.2.11",
22
+ "@jskit-ai/http-web": "0.2.19",
23
+ "@jskit-ai/kernel": "0.2.11",
24
+ "@jskit-ai/shell-web": "0.2.14",
25
25
  "@tanstack/vue-query": "^5.90.5",
26
26
  "vue": "^3.5.13",
27
27
  "vue-router": "^5.0.4",
@@ -230,6 +230,6 @@
230
230
  }
231
231
  },
232
232
  "devDependencies": {
233
- "@jskit-ai/material-web": "0.1.2"
233
+ "@jskit-ai/material-web": "0.1.3"
234
234
  }
235
235
  }
@@ -38,6 +38,18 @@ app's language; the app does, for example
38
38
  `watch(useAccountLocale(), (code) => { if (code) i18n.global.locale.value = code; }, { immediate: true })`.
39
39
  Section titles may be plain text, a message key or `{ key, fallback }`.
40
40
 
41
+ Sections are pages of the account surface, in Material 3's list-detail layout
42
+ (shell-web's `ShellListDetail`): `/account` is the section list
43
+ (`src/pages/account/index.vue`) and `/account/<section>` one section
44
+ (`src/pages/account/[section].vue`, its `value`, such as
45
+ `/account/preferences`), both rendering `AccountSettingsClientElement`, which
46
+ reads the section from the route's `section` param. The section page's parent
47
+ is the list, the surface's `"/"`. A phone or medium window shows the list or one
48
+ section, and Up returns from the section to the list; an expanded or large
49
+ window shows the list beside the open section, or beside the first section on
50
+ the list's own page. A link to a section is its path, `/account/invites`, with
51
+ `returnTo` in the query for Done.
52
+
41
53
  `runtime.signInMethods.methods` lists the auth provider's OAuth sign-in
42
54
  methods as `{ provider, label, linked, canLink, canUnlink }`, read from the
43
55
  settings security status. `link(provider)` leaves for the provider through
@@ -59,12 +71,19 @@ provider offers provider linking (`providerLinking` in its capabilities).
59
71
  - The route and placement owner agree.
60
72
  - The account surface renders in its own layout, `src/pages/account.vue`
61
73
  (`ShellLayout` around `<RouterView />`, `meta.jskit.surface: "account"`), so
62
- the settings page's title, Up arrow and Done action are in the top app bar.
74
+ the settings page's title and Up arrow are in the top app bar. Up is the
75
+ page's only way out; `AccountSettingsClientElement` adds a Done action only
76
+ where Up can't go back (no parent, a section beside the list on a wide
77
+ window, or a returnTo on another origin).
63
78
  With no navigation items, the surface shows no navigation bar, rail or drawer.
79
+ - Each section has its own address, `/account/<section>`, served by
80
+ `src/pages/account/[section].vue` with parent `"/"`; nothing selects a
81
+ section through the query.
64
82
 
65
83
  ## Example files
66
84
 
67
- `example/` contains the account surface's layout, an account route and four
85
+ `example/` contains the account surface's layout, the section list's route
86
+ (`account/index.vue`), a section's route (`account/[section].vue`) and four
68
87
  concrete settings sections.
69
88
  Adapt their fields and copy while retaining the shared account runtime.
70
89
 
@@ -77,7 +96,9 @@ notification vocabulary while keeping server-owned validation authoritative.
77
96
 
78
97
  Navigate away and back with warm cache, exercise browser back/forward, save and
79
98
  clear fields, verify toast errors, inspect skeleton geometry, and test keyboard
80
- operation at compact and expanded widths.
99
+ operation at compact and expanded widths. At 390 px check that the list and a
100
+ section show one at a time and Up returns from the section to the list; at
101
+ 1280 px that the list shows beside the open section, with no Up.
81
102
 
82
103
  ## Avoid
83
104
 
@@ -35,11 +35,11 @@ const notifications = props.runtime.notifications;
35
35
  color="primary"
36
36
  hide-details
37
37
  disabled
38
- class="mb-4"
39
38
  />
40
39
  <v-btn
41
40
  type="submit"
42
41
  color="primary"
42
+ class="mt-4"
43
43
  :loading="notifications.isSaving.value"
44
44
  :disabled="notifications.isRefreshing.value"
45
45
  >
@@ -94,7 +94,7 @@ const preferences = props.runtime.preferences;
94
94
  />
95
95
  </v-col>
96
96
 
97
- <v-col cols="12" md="3">
97
+ <v-col cols="12" md="4">
98
98
  <v-select
99
99
  v-model.number="preferences.form.avatarSize"
100
100
  label="Avatar size"
@@ -110,6 +110,7 @@ const preferences = props.runtime.preferences;
110
110
  <v-btn
111
111
  type="submit"
112
112
  color="primary"
113
+ class="mt-4"
113
114
  :loading="preferences.isSaving.value"
114
115
  :disabled="preferences.isRefreshing.value"
115
116
  >
@@ -51,7 +51,7 @@ const profile = props.runtime.profile;
51
51
  </v-row>
52
52
 
53
53
  <v-row>
54
- <v-col cols="12" md="6">
54
+ <v-col cols="12">
55
55
  <v-text-field
56
56
  v-model="profile.form.displayName"
57
57
  label="Display name"
@@ -63,7 +63,7 @@ const profile = props.runtime.profile;
63
63
  />
64
64
  </v-col>
65
65
 
66
- <v-col cols="12" md="6">
66
+ <v-col cols="12">
67
67
  <v-text-field
68
68
  v-model="profile.form.email"
69
69
  label="Email"
@@ -79,6 +79,7 @@ const profile = props.runtime.profile;
79
79
  <v-btn
80
80
  type="submit"
81
81
  color="primary"
82
+ class="mt-4"
82
83
  :loading="profile.isSaving.value"
83
84
  :disabled="profile.isDeletingAvatar.value || profile.isRefreshing.value"
84
85
  >
@@ -0,0 +1,23 @@
1
+ <route lang="json">
2
+ {
3
+ "meta": {
4
+ "guard": {
5
+ "policy": "authenticated"
6
+ },
7
+ "jskit": {
8
+ "title": "Account settings",
9
+ "parent": "/"
10
+ }
11
+ }
12
+ }
13
+ </route>
14
+
15
+ <template>
16
+ <AccountSettingsClientElement />
17
+ </template>
18
+
19
+ <script setup>
20
+ // One section of account settings (/account/preferences). Its parent is the section list, the surface's "/", so on a
21
+ // phone Up returns to the list; a wide window shows the list beside the section.
22
+ import AccountSettingsClientElement from "@jskit-ai/users-web/client/components/AccountSettingsClientElement";
23
+ </script>
@@ -9,14 +9,15 @@
9
9
  </route>
10
10
 
11
11
  <script setup>
12
- // The account surface's layout: the shell's top app bar carries the settings page's title, its Up arrow back to home
13
- // and its Done action. The surface has no navigation items, so the shell draws no navigation bar, rail or drawer.
12
+ // The account surface's layout: the shell's top app bar carries the settings page's title and its Up arrow back to
13
+ // home, the page's only way out (a Done action shows only where Up can't go back, such as a returnTo on another of
14
+ // the app's origins). The surface has no navigation items, so the shell draws no navigation bar, rail or drawer.
14
15
  import ShellLayout from "@/components/ShellLayout.vue";
15
16
  import { RouterView } from "vue-router";
16
17
  </script>
17
18
 
18
19
  <template>
19
- <ShellLayout title="" subtitle="">
20
+ <ShellLayout>
20
21
  <RouterView />
21
22
  </ShellLayout>
22
23
  </template>
@@ -1,10 +1,13 @@
1
1
  <script setup>
2
2
  import { computed } from "vue";
3
- import { useRoute, useRouter } from "vue-router";
4
- import { normalizeOneOf } from "@jskit-ai/kernel/shared/support/normalize";
3
+ import { useRoute } from "vue-router";
4
+ import ShellListDetail from "@jskit-ai/shell-web/client/components/ShellListDetail";
5
5
  import ShellPageAction from "@jskit-ai/shell-web/client/components/ShellPageAction";
6
6
  import ShellPageActions from "@jskit-ai/shell-web/client/components/ShellPageActions";
7
7
  import { useShellPageTitle } from "@jskit-ai/shell-web/client/composables/useShellPageTitle";
8
+ import { useShellWindowSizeClass } from "@jskit-ai/shell-web/client/composables/useShellWindowSizeClass";
9
+ import { usePaths } from "@jskit-ai/shell-web/client/navigation/usePaths";
10
+ import { useShellNavigation } from "@jskit-ai/shell-web/client/navigation/useShellNavigation";
8
11
  import { useAccountSettingsRuntime } from "../composables/useAccountSettingsRuntime.js";
9
12
  import { useAccountSettingsSections } from "../account-settings/sections.js";
10
13
  import { useJskitText } from "../i18n.js";
@@ -12,12 +15,16 @@ import { useJskitText } from "../i18n.js";
12
15
  const { t, text } = useJskitText();
13
16
  const runtime = useAccountSettingsRuntime();
14
17
  const route = useRoute();
15
- const router = useRouter();
18
+ const paths = usePaths();
16
19
  const sections = useAccountSettingsSections();
17
- // The app bar carries the page's title and its Done action, which returns to where the person came from (returnTo,
18
- // which may be another of the app's origins).
20
+ // The app bar carries the page's title. Its Up arrow, on a page that declares a parent, is the way out; a Done action
21
+ // beside it would be a second back control. Done shows only where Up can't take the person back: a page without a
22
+ // parent, an open section beside the list on a wide window (the list is the parent, already in view), or a returnTo
23
+ // on another of the app's origins.
19
24
  useShellPageTitle(() => t("jskit.users.accountSettings.title"));
25
+ const { canGoUp } = useShellNavigation();
20
26
  const doneTarget = computed(() => runtime.backNavigationTarget.value);
27
+ const showsDone = computed(() => !canGoUp.value || !doneTarget.value.sameOrigin);
21
28
  const sectionValues = computed(() => Object.freeze(sections.value.map((section) => section.value)));
22
29
  const defaultSection = computed(() => {
23
30
  if (sectionValues.value.includes("profile")) {
@@ -27,45 +34,31 @@ const defaultSection = computed(() => {
27
34
  return sectionValues.value[0] || "";
28
35
  });
29
36
 
30
- function normalizeSection(value) {
31
- const source = Array.isArray(value) ? value[0] : value;
32
- return normalizeOneOf(source, sectionValues.value, defaultSection.value);
33
- }
37
+ // Sections are pages: the surface's "/" is the list (/account) and "/<section>" one section (/account/preferences),
38
+ // the app's src/pages/account/[section].vue, whose parent is the list. Compact and medium windows show one pane at a
39
+ // time, so the list leads to a section and Up returns to it; wide windows show the list beside the open section, or
40
+ // beside the first section on the list's own page.
41
+ const openSection = computed(() => {
42
+ const value = route.params?.section;
43
+ const section = String(Array.isArray(value) ? value[0] : value || "").trim().toLowerCase();
44
+ return sectionValues.value.includes(section) ? section : "";
45
+ });
46
+ const { isExpanded, isLarge } = useShellWindowSizeClass();
47
+ const shownSection = computed(() => {
48
+ const value = openSection.value || (isExpanded.value || isLarge.value ? defaultSection.value : "");
49
+ return sections.value.find((section) => section.value === value) || null;
50
+ });
34
51
 
35
- function readRouteSection() {
36
- return normalizeSection(route?.query?.section);
52
+ // A section's page keeps the address's returnTo, where Done leads.
53
+ function sectionTarget(value) {
54
+ const returnTo = route.query?.returnTo;
55
+ return { path: paths.page(`/${value}`), query: returnTo ? { returnTo } : {} };
37
56
  }
38
-
39
- const activeTab = computed({
40
- get() {
41
- return readRouteSection();
42
- },
43
- set(nextValue) {
44
- const normalizedSection = normalizeSection(nextValue);
45
- const currentSection = readRouteSection();
46
- if (!normalizedSection || normalizedSection === currentSection) {
47
- return;
48
- }
49
-
50
- const nextQuery = {
51
- ...route.query
52
- };
53
- if (normalizedSection === defaultSection.value) {
54
- delete nextQuery.section;
55
- } else {
56
- nextQuery.section = normalizedSection;
57
- }
58
-
59
- void router.replace({
60
- query: nextQuery
61
- });
62
- }
63
- });
64
57
  </script>
65
58
 
66
59
  <template>
67
60
  <section class="settings-view py-2 py-md-4">
68
- <ShellPageActions>
61
+ <ShellPageActions v-if="showsDone">
69
62
  <ShellPageAction
70
63
  :label="t('jskit.users.accountSettings.done')"
71
64
  :to="doneTarget.sameOrigin ? doneTarget.href : undefined"
@@ -99,36 +92,28 @@ const activeTab = computed({
99
92
  </template>
100
93
  <template v-else>
101
94
  <v-progress-linear v-if="runtime.refreshingSettings.value" indeterminate class="mb-4" />
102
- <v-row class="settings-layout" no-gutters>
103
- <v-col cols="12" md="3" lg="2" class="pr-md-4 mb-4 mb-md-0">
95
+ <ShellListDetail :detail-open="Boolean(openSection)">
96
+ <template #list>
104
97
  <v-list nav class="settings-section-list">
105
98
  <v-list-item
106
99
  v-for="section in sections"
107
100
  :key="section.value"
101
+ class="settings-section-list__item"
108
102
  :title="text(section.title)"
109
- :active="activeTab === section.value"
110
- @click="activeTab = section.value"
103
+ :to="sectionTarget(section.value)"
104
+ :active="shownSection?.value === section.value"
111
105
  />
112
106
  </v-list>
113
- </v-col>
114
-
115
- <v-col cols="12" md="9" lg="10">
116
- <v-window v-model="activeTab" :touch="false" class="settings-sections-window">
117
- <v-window-item
118
- v-for="section in sections"
119
- :key="section.value"
120
- :value="section.value"
121
- :transition="false"
122
- :reverse-transition="false"
123
- >
124
- <component
125
- :is="section.component"
126
- v-bind="section.usesSharedRuntime ? { runtime } : undefined"
127
- />
128
- </v-window-item>
129
- </v-window>
130
- </v-col>
131
- </v-row>
107
+ </template>
108
+ <template #detail>
109
+ <div v-if="shownSection" :key="shownSection.value" class="settings-section-detail">
110
+ <component
111
+ :is="shownSection.component"
112
+ v-bind="shownSection.usesSharedRuntime ? { runtime } : undefined"
113
+ />
114
+ </div>
115
+ </template>
116
+ </ShellListDetail>
132
117
  </template>
133
118
  </div>
134
119
  </div>
@@ -149,6 +134,28 @@ const activeTab = computed({
149
134
  padding: 1rem;
150
135
  }
151
136
 
137
+ /* The selected section is Material 3's pill indicator in secondary-container, as the shell's navigation draws it, and
138
+ its hover and pressed state layers take the same pill. */
139
+ .settings-section-list__item {
140
+ --v-activated-opacity: 0;
141
+ border-radius: 9999px;
142
+ min-height: 56px;
143
+ color: rgb(var(--v-theme-on-surface-variant));
144
+ }
145
+
146
+ .settings-section-list__item.v-list-item--active {
147
+ background: rgb(var(--v-theme-secondary-container));
148
+ color: rgb(var(--v-theme-on-secondary-container));
149
+ }
150
+
151
+ /* Material 3's navigation item label (label-large), not Vuetify's 13px nav list title. */
152
+ .settings-section-list__item :deep(.v-list-item-title) {
153
+ font-size: 0.875rem;
154
+ font-weight: 500;
155
+ letter-spacing: 0.00625rem;
156
+ line-height: 1.25rem;
157
+ }
158
+
152
159
  .settings-panel__state {
153
160
  margin-inline: auto;
154
161
  max-width: 30rem;
@@ -50,7 +50,7 @@
50
50
  </v-row>
51
51
 
52
52
  <v-row>
53
- <v-col cols="12" md="6">
53
+ <v-col cols="12">
54
54
  <v-text-field
55
55
  v-model="state.profileForm.displayName"
56
56
  :label="copyText.displayName"
@@ -60,7 +60,7 @@
60
60
  :error-messages="state.profileFieldErrors.displayName"
61
61
  />
62
62
  </v-col>
63
- <v-col cols="12" md="6">
63
+ <v-col cols="12">
64
64
  <v-text-field
65
65
  v-model="state.profileForm.email"
66
66
  :label="copyText.email"
@@ -72,7 +72,13 @@
72
72
  />
73
73
  </v-col>
74
74
  </v-row>
75
- <v-btn type="submit" color="primary" :loading="state.profileMutation.isPending.value" :data-testid="uiTestIds.submitButton">
75
+ <v-btn
76
+ type="submit"
77
+ color="primary"
78
+ class="mt-4"
79
+ :loading="state.profileMutation.isPending.value"
80
+ :data-testid="uiTestIds.submitButton"
81
+ >
76
82
  {{ copyText.saveProfile }}
77
83
  </v-btn>
78
84
 
@@ -55,8 +55,8 @@ function document(attributes, type = "user-settings") {
55
55
  }
56
56
 
57
57
  /** One page with the settings API behind it; `routes` answers "METHOD /path", anything else is a test failure. */
58
- async function openApp({ routes = {} } = {}) {
59
- const page = await browser.newPage({ baseURL: runtime.baseURL, viewport: { width: 1280, height: 900 } });
58
+ async function openApp({ routes = {}, viewport = { width: 1280, height: 900 } } = {}) {
59
+ const page = await browser.newPage({ baseURL: runtime.baseURL, viewport, reducedMotion: "reduce" });
60
60
  page.setDefaultTimeout(15_000);
61
61
  page.setDefaultNavigationTimeout(60_000);
62
62
  const api = { requests: [], unexpected: [], errors: [], page };
@@ -123,13 +123,15 @@ test("the profile section loads, saves, shows a refused field and removes the av
123
123
  }
124
124
  });
125
125
  const { page } = api;
126
- await page.goto("/account?returnTo=/home");
126
+ // A section open beside the list: the list, its parent, is in view, so there is no Up, and Done returns to returnTo.
127
+ await page.goto("/account/profile?returnTo=/home");
127
128
  const name = page.getByLabel("Display name");
128
129
  await expect(name).toHaveValue("Ada");
129
130
  await expect(page.getByLabel("Email", { exact: true })).toHaveValue("ada@example.com");
130
- // The page's Done action (in the shell's app bar, or in place without one) returns to returnTo; there is no in-page h1.
131
131
  await expect(page.getByRole("link", { name: "Done" })).toHaveAttribute("href", "/home");
132
- await expect(page.getByRole("heading", { level: 1 })).toHaveCount(0);
132
+ await expect(page.getByTestId("jskit-shell-back")).toHaveCount(0);
133
+ // The app bar's title is the page's one h1.
134
+ await expect(page.getByRole("heading", { level: 1 })).toHaveText("Account settings");
133
135
 
134
136
  await name.fill(" Ada Lovelace ");
135
137
  await page.getByRole("button", { name: "Save profile" }).click();
@@ -151,7 +153,58 @@ test("the profile section loads, saves, shows a refused field and removes the av
151
153
 
152
154
  await page.getByRole("link", { name: "Done" }).click();
153
155
  await expect(page.getByTestId("home")).toBeVisible();
156
+
157
+ // The list's own page is an inner page of home: Up is its only way out, with no Done beside it.
158
+ await page.goto("/account?returnTo=/home");
159
+ await expect(page.getByLabel("Display name")).toHaveValue("Ada");
160
+ await expect(page.getByTestId("jskit-shell-back")).toBeVisible();
161
+ await expect(page.getByRole("link", { name: "Done" })).toHaveCount(0);
162
+ await finish(api);
163
+ });
164
+
165
+ // Material 3's list-detail layout: a phone shows the section list or one section, and Up returns from the section to
166
+ // the list and from the list to home; a wide window shows the list beside the open section, with no Up to the list.
167
+ test("a phone shows the section list and one section at a time, and Up returns to the list", options, async () => {
168
+ const api = await openApp({ viewport: { width: 390, height: 844 } });
169
+ const { page } = api;
170
+ const up = page.getByTestId("jskit-shell-back");
171
+ const sectionLinks = page.getByRole("link", { name: /^(Profile|Preferences|Notifications|Sign-in methods)$/u });
172
+ await page.goto("/account");
173
+ await expect(sectionLinks).toHaveCount(4);
174
+ await expect(page.getByLabel("Display name")).toHaveCount(0);
175
+ await expect(page.locator(".settings-section-list__item.v-list-item--active")).toHaveCount(0);
176
+
177
+ await page.getByRole("link", { name: "Preferences" }).click();
178
+ await page.waitForURL("**/account/preferences");
179
+ await expect(page.getByRole("button", { name: /Save preferences/u })).toBeVisible();
180
+ await expect(sectionLinks.first()).toBeHidden();
181
+ await expect(page.getByRole("link", { name: "Done" })).toHaveCount(0);
182
+
183
+ await up.click();
184
+ await page.waitForURL((url) => url.pathname === "/account");
185
+ await expect(sectionLinks.first()).toBeVisible();
186
+ await expect(page.getByRole("button", { name: /Save preferences/u })).toHaveCount(0);
187
+
188
+ // A section opened from a pasted link has the list as its parent too.
189
+ await page.goto("/account/notifications");
190
+ await expect(page.getByRole("button", { name: /Save notification settings/u })).toBeVisible();
191
+ await up.click();
192
+ await page.waitForURL((url) => url.pathname === "/account");
193
+ await up.click();
194
+ await expect(page.getByTestId("home")).toBeVisible();
154
195
  await finish(api);
196
+
197
+ const wide = await openApp();
198
+ await wide.page.goto("/account");
199
+ await expect(wide.page.getByRole("link", { name: "Preferences" })).toBeVisible();
200
+ await expect(wide.page.getByLabel("Display name")).toBeVisible();
201
+ await expect(wide.page.locator(".settings-section-list__item.v-list-item--active")).toHaveText("Profile");
202
+ await wide.page.getByRole("link", { name: "Preferences" }).click();
203
+ await wide.page.waitForURL("**/account/preferences");
204
+ await expect(wide.page.getByRole("link", { name: "Profile" })).toBeVisible();
205
+ await expect(wide.page.getByRole("button", { name: /Save preferences/u })).toBeVisible();
206
+ await expect(wide.page.getByTestId("jskit-shell-back")).toHaveCount(0);
207
+ await finish(wide);
155
208
  });
156
209
 
157
210
  test("saved preferences follow into the theme, the stored preference and the account locale", options, async () => {
@@ -168,8 +221,8 @@ test("saved preferences follow into the theme, the stored preference and the acc
168
221
  await expect(page.getByTestId("account-locale")).toHaveText("it-IT");
169
222
  assert.equal(await page.evaluate(() => localStorage.getItem("jskit.themePreference")), "dark");
170
223
 
171
- await page.getByRole("listitem").filter({ hasText: "Preferences" }).click();
172
- await page.waitForURL("**/account?section=preferences");
224
+ await page.getByRole("link", { name: "Preferences" }).click();
225
+ await page.waitForURL("**/account/preferences");
173
226
  await page.getByRole("button", { name: /Save preferences/u }).click();
174
227
  await expect.poll(() => lastReport(page)).toBe("Preferences updated.");
175
228
  const saved = api.requests.find((request) => request.key === "PATCH /api/settings/preferences");
@@ -191,7 +244,7 @@ test("a refused preference shows its message", options, async () => {
191
244
  }
192
245
  });
193
246
  const { page } = api;
194
- await page.goto("/account?section=preferences");
247
+ await page.goto("/account/preferences");
195
248
  await page.getByRole("button", { name: /Save preferences/u }).click();
196
249
  await expect(page.getByText("Theme is not allowed.")).toBeVisible();
197
250
  // The old error array looped here for seconds before Vue gave up, so the page must also stay idle.
@@ -211,19 +264,61 @@ test("notifications save, and sign-in methods link through the provider and unli
211
264
  }
212
265
  });
213
266
  const { page } = api;
214
- await page.goto("/account?section=notifications");
267
+ await page.goto("/account/notifications");
215
268
  await page.getByRole("button", { name: /Save notification settings/u }).click();
216
269
  await expect.poll(() => lastReport(page)).toBe("Notification settings updated.");
217
270
  const saved = api.requests.find((request) => request.key === "PATCH /api/settings/notifications");
218
271
  assert.deepEqual(saved.body.data.attributes, { productUpdates: true, accountActivity: false, securityAlerts: true });
219
272
 
220
- await page.getByRole("listitem").filter({ hasText: "Sign-in methods" }).click();
273
+ await page.getByRole("link", { name: "Sign-in methods" }).click();
221
274
  await page.getByRole("button", { name: "Unlink" }).click();
222
275
  await expect.poll(() => lastReport(page)).toBe("Sign-in method removed.");
223
276
  await expect(page.getByRole("button", { name: "Unlink" })).toHaveCount(0);
224
277
  await page.getByRole("button", { name: "Link" }).click();
225
278
  await page.getByText("Provider").waitFor();
226
- assert.equal(api.requests.at(-1).search, "?returnTo=%2Faccount%3Fsection%3Dsign-in-methods");
279
+ assert.equal(api.requests.at(-1).search, "?returnTo=%2Faccount%2Fsign-in-methods");
280
+ await finish(api);
281
+ });
282
+
283
+ // Material 3 type and layout: the section list's labels and the buttons are label-large (14px/500), a switch sits
284
+ // after its label at the row's end, and each field has the form's full width, so a long email shows whole.
285
+ test("account settings use Material 3 label type, trailing switches and full-width fields", options, async () => {
286
+ const email = "ada.lovelace.analytical.engine@difference-engine.example.com";
287
+ const api = await openApp({
288
+ routes: { "GET /api/settings": document(settings({ profile: { ...settings().profile, email } })) }
289
+ });
290
+ const { page } = api;
291
+ await page.goto("/account");
292
+ await expect(page.getByLabel("Email", { exact: true })).toHaveValue(email);
293
+ const type = (locator) => locator.evaluate((element) => {
294
+ const style = window.getComputedStyle(element);
295
+ return [style.fontSize, style.fontWeight];
296
+ });
297
+ assert.deepEqual(await type(page.locator(".settings-section-list__item .v-list-item-title").first()), ["14px", "500"]);
298
+ assert.deepEqual(await type(page.getByRole("button", { name: "Save profile" })), ["14px", "500"]);
299
+ const fields = await page.evaluate(() => {
300
+ const form = document.querySelector(".settings-section-detail form");
301
+ const width = (element) => Math.round(element.getBoundingClientRect().width);
302
+ return [...form.querySelectorAll(".v-text-field")].map((field) => {
303
+ const input = field.querySelector("input");
304
+ return { width: width(field), form: width(form), whole: input.scrollWidth <= input.clientWidth };
305
+ });
306
+ });
307
+ assert.ok(fields.length >= 2, "the profile form has its fields");
308
+ for (const field of fields) {
309
+ assert.ok(field.width >= field.form - 48, `a field has the form's width: ${JSON.stringify(field)}`);
310
+ assert.equal(field.whole, true, `a field shows its value whole: ${JSON.stringify(field)}`);
311
+ }
312
+
313
+ await page.goto("/account/notifications");
314
+ const switches = await page.locator(".v-switch").evaluateAll((elements) => elements.map((element) => {
315
+ const label = element.querySelector(".v-label").getBoundingClientRect();
316
+ const control = element.querySelector(".v-selection-control__wrapper").getBoundingClientRect();
317
+ const row = element.getBoundingClientRect();
318
+ return { after: control.left >= label.right, atEnd: Math.abs(row.right - control.right) <= 1, labelAtStart: Math.abs(label.left - row.left) <= 1 };
319
+ }));
320
+ assert.ok(switches.length >= 3, "the notification switches render");
321
+ for (const position of switches) assert.deepEqual(position, { after: true, atEnd: true, labelAtStart: true });
227
322
  await finish(api);
228
323
  });
229
324
 
@@ -265,7 +360,7 @@ test("each section stays busy until its save or removal answers", options, async
265
360
  await expect(removeAvatar).not.toHaveClass(loading);
266
361
  await expect(saveProfile).toBeEnabled();
267
362
 
268
- await page.getByRole("listitem").filter({ hasText: "Preferences" }).click();
363
+ await page.getByRole("link", { name: "Preferences" }).click();
269
364
  const savePreferences = page.getByRole("button", { name: /Save preferences/u });
270
365
  const disabledFields = page.locator(".v-input--disabled");
271
366
  await savePreferences.click();
@@ -275,7 +370,7 @@ test("each section stays busy until its save or removal answers", options, async
275
370
  await expect(savePreferences).not.toHaveClass(loading);
276
371
  await expect(disabledFields).toHaveCount(0);
277
372
 
278
- await page.getByRole("listitem").filter({ hasText: "Notifications" }).click();
373
+ await page.getByRole("link", { name: "Notifications" }).click();
279
374
  const saveNotifications = page.getByRole("button", { name: /Save notification settings/u });
280
375
  const productUpdates = page.getByLabel("Product updates");
281
376
  await saveNotifications.click();
@@ -290,16 +385,17 @@ test("each section stays busy until its save or removal answers", options, async
290
385
  test("the section list follows the address and the placements, and says when there is nothing to show", options, async () => {
291
386
  const api = await openApp();
292
387
  const { page } = api;
293
- await page.goto("/account?section=unknown");
388
+ // A section no placement names opens none: the list, with the first section beside it on a wide window.
389
+ await page.goto("/account/unknown");
294
390
  await expect(page.getByLabel("Display name")).toBeVisible();
295
391
 
296
392
  await page.evaluate(() => window.accountFixture.placeSection());
297
- await page.getByRole("listitem").filter({ hasText: "Extra" }).click();
393
+ await page.getByRole("link", { name: "Extra" }).click();
298
394
  await page.getByText("Extra section").waitFor();
299
- await page.waitForURL("**/account?section=extra");
300
- await page.getByRole("listitem").filter({ hasText: "Profile" }).click();
395
+ await page.waitForURL("**/account/extra");
396
+ await page.getByRole("link", { name: "Profile" }).click();
301
397
  await expect(page.getByLabel("Display name")).toBeVisible();
302
- await page.waitForURL((url) => url.search === "");
398
+ await page.waitForURL("**/account/profile");
303
399
  await finish(api);
304
400
 
305
401
  const empty = await openApp();
@@ -0,0 +1,24 @@
1
+ import assert from "node:assert/strict";
2
+ import { readFile } from "node:fs/promises";
3
+ import test from "node:test";
4
+
5
+ // The users/account-settings pattern lays its sections out as pages: the section list at the account surface's "/",
6
+ // an inner page of home, and one section at "/<section>", whose parent is the list, both rendering the element.
7
+ const PAGES = new URL("../patterns/account-settings/example/src/pages/", import.meta.url);
8
+
9
+ async function routeMeta(file) {
10
+ const source = await readFile(new URL(file, PAGES), "utf8");
11
+ const block = /<route lang="json">([\s\S]*?)<\/route>/u.exec(source);
12
+ assert.ok(block, `${file} declares its route meta`);
13
+ assert.match(source, /<AccountSettingsClientElement \/>/u, `${file} renders AccountSettingsClientElement`);
14
+ return JSON.parse(block[1]).meta;
15
+ }
16
+
17
+ test("the account-settings pattern's list and section pages declare their parents", async () => {
18
+ const list = await routeMeta("account/index.vue");
19
+ assert.deepEqual(list.jskit.parent, { surface: "home", path: "/" });
20
+ const section = await routeMeta("account/[section].vue");
21
+ assert.equal(section.jskit.parent, "/");
22
+ assert.deepEqual(section.guard, list.guard);
23
+ assert.equal(section.jskit.title, list.jskit.title);
24
+ });
@@ -90,7 +90,9 @@ test("package-owned account settings host is placement-backed rather than scaffo
90
90
  );
91
91
 
92
92
  assert.match(source, /useAccountSettingsSections/);
93
- // The app bar carries the title and the Done action; the page keeps only its intro.
93
+ // The app bar carries the title, and Done only where Up can't go back; the page keeps only its intro.
94
+ assert.match(source, /<ShellPageActions v-if="showsDone">/u);
95
+ assert.match(source, /const showsDone = computed\(\(\) => !canGoUp\.value \|\| !doneTarget\.value\.sameOrigin\);/u);
94
96
  assert.match(source, /settings-panel__intro/);
95
97
  assert.match(source, /useShellPageTitle/);
96
98
  assert.doesNotMatch(source, /<h1/u);
@@ -295,6 +295,15 @@ test("the profile menu and a refused, then retried, sign-out show only the pseud
295
295
  });
296
296
  const { page } = api;
297
297
  await api.open("/home");
298
+ // On a phone, where the name is hidden, the activator is a round icon button around the avatar.
299
+ await page.setViewportSize({ width: 390, height: 844 });
300
+ const activator = await page.locator(".auth-profile-widget__activator").evaluate((element) => {
301
+ const box = element.getBoundingClientRect();
302
+ return { width: box.width, height: box.height, radius: Number.parseFloat(window.getComputedStyle(element).borderTopLeftRadius) };
303
+ });
304
+ assert.equal(Math.round(activator.width), Math.round(activator.height), "The phone activator is as wide as it is tall.");
305
+ assert.ok(activator.radius >= activator.height / 2, `The phone activator's corner radius was ${activator.radius}px.`);
306
+ await page.setViewportSize({ width: 1280, height: 900 });
298
307
  await page.locator(".auth-profile-widget__activator").click();
299
308
  const signOut = page.getByRole("link", { name: shown("app.menu.signOut") });
300
309
  await expect(signOut).toBeVisible();
@@ -371,9 +380,28 @@ test("account settings, their feedback and a refused field show only the pseudo-
371
380
  await api.open("/account?returnTo=/home");
372
381
  await expect(page.getByText(shown("jskit.users.accountSettings.profile.save"))).toBeVisible();
373
382
  await assertTranslated(page, "account settings");
383
+ // The selected section carries Material 3's pill indicator in secondary-container, not a grey rectangle.
384
+ const selected = await page.locator(".settings-section-list .v-list-item--active").evaluate((element) => {
385
+ const probe = document.createElement("div");
386
+ probe.style.background = "rgb(var(--v-theme-secondary-container))";
387
+ document.body.append(probe);
388
+ const indicator = window.getComputedStyle(probe).backgroundColor;
389
+ probe.remove();
390
+ const style = window.getComputedStyle(element);
391
+ return { background: style.backgroundColor, indicator, radius: Number.parseFloat(style.borderTopLeftRadius), height: element.getBoundingClientRect().height };
392
+ });
393
+ assert.equal(selected.background, selected.indicator);
394
+ assert.ok(selected.radius >= selected.height / 2, `The selected section's corner radius was ${selected.radius}px.`);
395
+ // Save keeps 16dp from the last field above it.
396
+ const saveGap = await page.getByTestId("profile-submit-button").evaluate((button) => {
397
+ const row = button.previousElementSibling;
398
+ const fields = [...row.querySelectorAll(".v-input")].map((field) => field.getBoundingClientRect().bottom);
399
+ return button.getBoundingClientRect().top - Math.max(...fields);
400
+ });
401
+ assert.ok(saveGap >= 15.5, `Save sat ${saveGap}px below the last field.`);
374
402
 
375
- await page.getByRole("listitem").filter({ hasText: shown("app.settings.preferences") }).click();
376
- await page.waitForURL("**section=preferences**");
403
+ await page.getByRole("link", { name: shown("app.settings.preferences") }).click();
404
+ await page.waitForURL((url) => url.pathname === "/account/preferences");
377
405
  await page.getByTestId("theme-select").click();
378
406
  await expect(page.getByRole("option", { name: shown("jskit.users.accountSettings.preferences.theme.light") })).toBeVisible();
379
407
  await assertTranslated(page, "the theme choices");