@happyvertical/smrt-users 0.30.0 → 0.31.1

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 (29) hide show
  1. package/dist/chunks/{TerminalAuthService-DoAMQ_yn.js → TerminalAuthService-DsQBk1Hc.js} +161 -71
  2. package/dist/chunks/TerminalAuthService-DsQBk1Hc.js.map +1 -0
  3. package/dist/chunks/{index-DkoYIvIu.js → index-Cp33Tyha.js} +10 -10
  4. package/dist/chunks/{index-DkoYIvIu.js.map → index-Cp33Tyha.js.map} +1 -1
  5. package/dist/collections/GroupMemberCollection.d.ts +9 -0
  6. package/dist/collections/GroupMemberCollection.d.ts.map +1 -1
  7. package/dist/collections/SessionCollection.d.ts.map +1 -1
  8. package/dist/index.js +38 -100
  9. package/dist/index.js.map +1 -1
  10. package/dist/manifest.json +2 -2
  11. package/dist/smrt-knowledge.json +4 -4
  12. package/dist/svelte/components/InviteUserModal.svelte +72 -169
  13. package/dist/svelte/components/InviteUserModal.svelte.d.ts.map +1 -1
  14. package/dist/svelte/components/UserCard.svelte +2 -1
  15. package/dist/svelte/components/UserCard.svelte.d.ts.map +1 -1
  16. package/dist/svelte/components/UserForm.svelte +11 -4
  17. package/dist/svelte/components/UserForm.svelte.d.ts.map +1 -1
  18. package/dist/svelte/components/UserMenu.svelte +100 -25
  19. package/dist/svelte/components/UserMenu.svelte.d.ts +5 -4
  20. package/dist/svelte/components/UserMenu.svelte.d.ts.map +1 -1
  21. package/dist/svelte/components/__tests__/InviteUserModal.test.js +11 -0
  22. package/dist/svelte/components/__tests__/UserMenu.test.js +45 -0
  23. package/dist/svelte/components/__tests__/UserStatus.test.js +36 -0
  24. package/dist/sveltekit/index.d.ts +7 -1
  25. package/dist/sveltekit/index.d.ts.map +1 -1
  26. package/dist/sveltekit.js +15 -8
  27. package/dist/sveltekit.js.map +1 -1
  28. package/package.json +8 -8
  29. package/dist/chunks/TerminalAuthService-DoAMQ_yn.js.map +0 -1
@@ -1,5 +1,6 @@
1
1
  <script lang="ts">
2
2
  import { RoleSelector } from '@happyvertical/smrt-ui';
3
+ import { Modal } from '@happyvertical/smrt-ui/feedback';
3
4
  import { useI18n } from '@happyvertical/smrt-ui/i18n';
4
5
  import type { Role, Tenant } from '@happyvertical/smrt-users';
5
6
  import { M } from '../i18n.js';
@@ -28,6 +29,8 @@ const {
28
29
  loading = false,
29
30
  }: Props = $props();
30
31
 
32
+ const formId = $props.id();
33
+
31
34
  let email = $state('');
32
35
  let roleId = $state('');
33
36
  let sendEmail = $state(true);
@@ -65,171 +68,81 @@ function handleClose() {
65
68
  error = '';
66
69
  onclose();
67
70
  }
68
-
69
- function handleBackdrop(e: MouseEvent) {
70
- if (e.target === e.currentTarget) {
71
- handleClose();
72
- }
73
- }
74
-
75
- function handleKeydown(e: KeyboardEvent) {
76
- if (e.key === 'Escape' && open) {
77
- handleClose();
78
- }
79
- }
80
71
  </script>
81
72
 
82
- <svelte:window onkeydown={handleKeydown} />
83
-
84
- {#if open}
85
- <div class="modal-backdrop">
86
- <button
87
- type="button"
88
- class="modal-overlay"
89
- aria-label={t(M['users.invite_user_modal.close_invite_dialog'])}
90
- onclick={handleClose}
91
- ></button>
92
- <div class="modal" role="dialog" aria-modal="true" tabindex="-1">
93
- <div class="header">
94
- <h2>{t(M['users.invite_user_modal.title'], { tenantName: tenant.name })}</h2>
95
- <button type="button" class="close-btn" onclick={handleClose} aria-label={t(M['users.invite_user_modal.close'])}>
96
- <svg viewBox="0 0 20 20" fill="currentColor">
97
- <path
98
- d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
99
- />
100
- </svg>
101
- </button>
102
- </div>
103
-
104
- <form onsubmit={handleSubmit}>
105
- <div class="body">
106
- {#if error}
107
- <div class="error">{error}</div>
108
- {/if}
109
-
110
- <div class="field">
111
- <label for="invite-email">{t(M['users.invite_user_modal.email_address'])}</label>
112
- <input
113
- id="invite-email"
114
- type="email"
115
- bind:value={email}
116
- placeholder={t(M['users.invite_user_modal.email_placeholder'])}
117
- disabled={loading}
118
- required
119
- />
120
- </div>
121
-
122
- <div class="field">
123
- <label for="invite-role">Role</label>
124
- <RoleSelector
125
- {roles}
126
- value={roleId}
127
- onchange={(id: string) => (roleId = id)}
128
- disabled={loading}
129
- showDescription
130
- />
131
- </div>
132
-
133
- <div class="checkbox-field">
134
- <input id="send-email" type="checkbox" bind:checked={sendEmail} disabled={loading} />
135
- <label for="send-email">{t(M['users.invite_user_modal.send_invitation_email'])}</label>
136
- </div>
137
-
138
- {#if !sendEmail}
139
- <div class="hint">
140
- {t(M['users.invite_user_modal.pending_hint'])}
141
- </div>
142
- {/if}
143
- </div>
144
-
145
- <div class="footer">
146
- <button type="button" class="btn-secondary" onclick={handleClose} disabled={loading}>
147
- Cancel
148
- </button>
149
- <button type="submit" class="btn-primary" disabled={loading}>
150
- {#if loading}
151
- {t(M['users.invite_user_modal.sending'])}
152
- {:else}
153
- {t(M['users.invite_user_modal.send_invite'])}
154
- {/if}
155
- </button>
156
- </div>
157
- </form>
73
+ <!--
74
+ Adopts the smrt-ui Modal (native <dialog>.showModal()) for the focus trap,
75
+ focus restore, top-layer rendering, and Escape handling that the previous
76
+ hand-rolled dialog lacked (#1399 a11y blocker).
77
+ -->
78
+ <Modal
79
+ {open}
80
+ onClose={handleClose}
81
+ size="sm"
82
+ title={t(M['users.invite_user_modal.title'], { tenantName: tenant.name })}
83
+ ariaLabel={t(M['users.invite_user_modal.title'], { tenantName: tenant.name })}
84
+ closeOnBackdrop={!loading}
85
+ closeOnEscape={!loading}
86
+ >
87
+ <form id={formId} onsubmit={handleSubmit}>
88
+ {#if error}
89
+ <div class="error">{error}</div>
90
+ {/if}
91
+
92
+ <div class="field">
93
+ <label for="invite-email">{t(M['users.invite_user_modal.email_address'])}</label>
94
+ <input
95
+ id="invite-email"
96
+ type="email"
97
+ bind:value={email}
98
+ placeholder={t(M['users.invite_user_modal.email_placeholder'])}
99
+ disabled={loading}
100
+ required
101
+ />
158
102
  </div>
159
- </div>
160
- {/if}
161
103
 
162
- <style>
163
- .modal-backdrop {
164
- position: fixed;
165
- inset: 0;
166
- display: flex;
167
- align-items: center;
168
- justify-content: center;
169
- padding: 1rem;
170
- z-index: var(--smrt-z-index-dialog, 1300);
171
- }
172
-
173
- .modal-overlay {
174
- position: absolute;
175
- inset: 0;
176
- border: none;
177
- background: var(--smrt-color-scrim, rgba(0, 0, 0, 0.5));
178
- cursor: pointer;
179
- }
180
-
181
- .modal {
182
- position: relative;
183
- background: var(--smrt-color-surface, white);
184
- border-radius: var(--smrt-radius-large, 0.5rem);
185
- box-shadow: var(--smrt-elevation-3, 0 20px 25px -5px rgba(0, 0, 0, 0.1));
186
- width: 100%;
187
- max-width: 28rem;
188
- max-height: 90vh;
189
- overflow: hidden;
190
- z-index: 1;
191
- }
192
-
193
- .header {
194
- display: flex;
195
- align-items: center;
196
- justify-content: space-between;
197
- padding: var(--smrt-spacing-md, 1rem) var(--smrt-spacing-lg, 1.5rem);
198
- border-bottom: 1px solid var(--smrt-color-outline-variant, #c4c6cf);
199
- }
200
-
201
- h2 {
202
- margin: 0;
203
- font: var(--smrt-typography-title-large-font, 600 1.125rem / 1.25 sans-serif);
204
- color: var(--smrt-color-on-surface, #1a1c1e);
205
- }
206
-
207
- .close-btn {
208
- display: flex;
209
- align-items: center;
210
- justify-content: center;
211
- width: 2rem;
212
- height: 2rem;
213
- background: none;
214
- border: none;
215
- border-radius: var(--smrt-radius-medium, 0.5rem);
216
- cursor: pointer;
217
- color: var(--smrt-color-on-surface-variant, #43474e);
218
- transition: background-color var(--smrt-duration-short2, 150ms) var(--smrt-easing-standard, ease);
219
- }
104
+ <div class="field">
105
+ <label for="invite-role">Role</label>
106
+ <RoleSelector
107
+ {roles}
108
+ value={roleId}
109
+ onchange={(id: string) => (roleId = id)}
110
+ disabled={loading}
111
+ showDescription
112
+ />
113
+ </div>
220
114
 
221
- .close-btn:hover {
222
- background: var(--smrt-color-surface-container, #f3f4f6);
223
- color: var(--smrt-color-on-surface, #1a1c1e);
224
- }
115
+ <div class="checkbox-field">
116
+ <input id="send-email" type="checkbox" bind:checked={sendEmail} disabled={loading} />
117
+ <label for="send-email">{t(M['users.invite_user_modal.send_invitation_email'])}</label>
118
+ </div>
225
119
 
226
- .close-btn svg {
227
- width: 1.25rem;
228
- height: 1.25rem;
229
- }
120
+ {#if !sendEmail}
121
+ <div class="hint">
122
+ {t(M['users.invite_user_modal.pending_hint'])}
123
+ </div>
124
+ {/if}
125
+ </form>
126
+
127
+ {#snippet footer()}
128
+ <button type="button" class="btn-secondary" onclick={handleClose} disabled={loading}>
129
+ Cancel
130
+ </button>
131
+ <button type="submit" form={formId} class="btn-primary" disabled={loading}>
132
+ {#if loading}
133
+ {t(M['users.invite_user_modal.sending'])}
134
+ {:else}
135
+ {t(M['users.invite_user_modal.send_invite'])}
136
+ {/if}
137
+ </button>
138
+ {/snippet}
139
+ </Modal>
230
140
 
231
- .body {
232
- padding: var(--smrt-spacing-lg, 1.5rem);
141
+ <style>
142
+ /* The dialog chrome (backdrop, surface, header, footer bar, close button) is
143
+ supplied by the smrt-ui Modal. Only the form-content + footer-button styles
144
+ live here. */
145
+ form {
233
146
  display: flex;
234
147
  flex-direction: column;
235
148
  gap: var(--smrt-spacing-md, 1rem);
@@ -298,15 +211,6 @@ function handleKeydown(e: KeyboardEvent) {
298
211
  border-radius: var(--smrt-radius-small, 0.25rem);
299
212
  }
300
213
 
301
- .footer {
302
- display: flex;
303
- justify-content: flex-end;
304
- gap: var(--smrt-spacing-sm, 0.75rem);
305
- padding: var(--smrt-spacing-md, 1rem) var(--smrt-spacing-lg, 1.5rem);
306
- background: var(--smrt-color-surface-container-low, #f9fafb);
307
- border-top: 1px solid var(--smrt-color-outline-variant, #c4c6cf);
308
- }
309
-
310
214
  button {
311
215
  padding: var(--smrt-spacing-sm, 0.5rem) var(--smrt-spacing-md, 1rem);
312
216
  border-radius: var(--smrt-radius-medium, 0.5rem);
@@ -343,8 +247,7 @@ function handleKeydown(e: KeyboardEvent) {
343
247
 
344
248
  @media (prefers-reduced-motion: reduce) {
345
249
  button,
346
- input[type='email'],
347
- .close-btn {
250
+ input[type='email'] {
348
251
  transition: none;
349
252
  }
350
253
  }
@@ -1 +1 @@
1
- {"version":3,"file":"InviteUserModal.svelte.d.ts","sourceRoot":"","sources":["../../../src/svelte/components/InviteUserModal.svelte.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,2BAA2B,CAAC;AAI9D,MAAM,WAAW,KAAK;IACpB,IAAI,EAAE,OAAO,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,IAAI,EAAE,CAAC;IACd,QAAQ,EAAE,CAAC,IAAI,EAAE;QACf,KAAK,EAAE,MAAM,CAAC;QACd,MAAM,EAAE,MAAM,CAAC;QACf,SAAS,EAAE,OAAO,CAAC;KACpB,KAAK,IAAI,CAAC;IACX,OAAO,EAAE,MAAM,IAAI,CAAC;IACpB,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AAsID,QAAA,MAAM,eAAe,2CAAwC,CAAC;AAC9D,KAAK,eAAe,GAAG,UAAU,CAAC,OAAO,eAAe,CAAC,CAAC;AAC1D,eAAe,eAAe,CAAC"}
1
+ {"version":3,"file":"InviteUserModal.svelte.d.ts","sourceRoot":"","sources":["../../../src/svelte/components/InviteUserModal.svelte.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,2BAA2B,CAAC;AAI9D,MAAM,WAAW,KAAK;IACpB,IAAI,EAAE,OAAO,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,IAAI,EAAE,CAAC;IACd,QAAQ,EAAE,CAAC,IAAI,EAAE;QACf,KAAK,EAAE,MAAM,CAAC;QACd,MAAM,EAAE,MAAM,CAAC;QACf,SAAS,EAAE,OAAO,CAAC;KACpB,KAAK,IAAI,CAAC;IACX,OAAO,EAAE,MAAM,IAAI,CAAC;IACpB,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AA4GD,QAAA,MAAM,eAAe,2CAAwC,CAAC;AAC9D,KAAK,eAAe,GAAG,UAAU,CAAC,OAAO,eAAe,CAAC,CAAC;AAC1D,eAAe,eAAe,CAAC"}
@@ -34,7 +34,8 @@ const statusClass = $derived.by(() => {
34
34
  return 'status-pending';
35
35
  case 'suspended':
36
36
  return 'status-error';
37
- case 'deactivated':
37
+ case 'inactive':
38
+ // `inactive` is the real UserStatus value (there is no `deactivated`).
38
39
  return 'status-disabled';
39
40
  default:
40
41
  return '';
@@ -1 +1 @@
1
- {"version":3,"file":"UserCard.svelte.d.ts","sourceRoot":"","sources":["../../../src/svelte/components/UserCard.svelte.ts"],"names":[],"mappings":"AAGA;;;GAGG;AACH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,8BAA8B,CAAC;AAE5D,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,2BAA2B,CAAC;AAItD,MAAM,WAAW,KAAK;IACpB,IAAI,EAAE,IAAI,CAAC;IACX,OAAO,EAAE,OAAO,CAAC;IACjB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,MAAM,IAAI,CAAC;IACrB,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAuDD,QAAA,MAAM,QAAQ,2CAAwC,CAAC;AACvD,KAAK,QAAQ,GAAG,UAAU,CAAC,OAAO,QAAQ,CAAC,CAAC;AAC5C,eAAe,QAAQ,CAAC"}
1
+ {"version":3,"file":"UserCard.svelte.d.ts","sourceRoot":"","sources":["../../../src/svelte/components/UserCard.svelte.ts"],"names":[],"mappings":"AAGA;;;GAGG;AACH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,8BAA8B,CAAC;AAE5D,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,2BAA2B,CAAC;AAItD,MAAM,WAAW,KAAK;IACpB,IAAI,EAAE,IAAI,CAAC;IACX,OAAO,EAAE,OAAO,CAAC;IACjB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,MAAM,IAAI,CAAC;IACrB,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAwDD,QAAA,MAAM,QAAQ,2CAAwC,CAAC;AACvD,KAAK,QAAQ,GAAG,UAAU,CAAC,OAAO,QAAQ,CAAC,CAAC;AAC5C,eAAe,QAAQ,CAAC"}
@@ -40,6 +40,14 @@ let status = $state(initialFormState.status);
40
40
  let appliedUser: User | null = initialFormState.user;
41
41
  let appliedProfile: Profile | null = initialFormState.profile;
42
42
 
43
+ // Drive the options from the enum so the form can never offer a value that
44
+ // isn't a real UserStatus (previously offered `deactivated`, which is not an
45
+ // enum member, and omitted `inactive`).
46
+ const statusOptions = Object.values(UserStatus).map((value) => ({
47
+ value,
48
+ label: value.charAt(0).toUpperCase() + value.slice(1),
49
+ }));
50
+
43
51
  $effect(() => {
44
52
  if (appliedUser === user && appliedProfile === profile) {
45
53
  return;
@@ -75,10 +83,9 @@ function handleSubmit(e: Event) {
75
83
  <div class="field">
76
84
  <label for="status">Status</label>
77
85
  <select id="status" bind:value={status} disabled={loading}>
78
- <option value="active">Active</option>
79
- <option value="pending">Pending</option>
80
- <option value="suspended">Suspended</option>
81
- <option value="deactivated">Deactivated</option>
86
+ {#each statusOptions as option (option.value)}
87
+ <option value={option.value}>{option.label}</option>
88
+ {/each}
82
89
  </select>
83
90
  </div>
84
91
 
@@ -1 +1 @@
1
- {"version":3,"file":"UserForm.svelte.d.ts","sourceRoot":"","sources":["../../../src/svelte/components/UserForm.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,8BAA8B,CAAC;AAC5D,OAAO,EAAE,UAAU,EAAE,MAAM,2BAA2B,CAAC;AAEvD,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,2BAA2B,CAAC;AAItD,MAAM,WAAW,KAAK;IACpB,IAAI,CAAC,EAAE,IAAI,GAAG,IAAI,CAAC;IACnB,OAAO,CAAC,EAAE,OAAO,GAAG,IAAI,CAAC;IACzB,QAAQ,EAAE,CAAC,IAAI,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,UAAU,CAAA;KAAE,KAAK,IAAI,CAAC;IAC9E,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAC;IACtB,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AAgGD,QAAA,MAAM,QAAQ,2CAAwC,CAAC;AACvD,KAAK,QAAQ,GAAG,UAAU,CAAC,OAAO,QAAQ,CAAC,CAAC;AAC5C,eAAe,QAAQ,CAAC"}
1
+ {"version":3,"file":"UserForm.svelte.d.ts","sourceRoot":"","sources":["../../../src/svelte/components/UserForm.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,8BAA8B,CAAC;AAC5D,OAAO,EAAE,UAAU,EAAE,MAAM,2BAA2B,CAAC;AAEvD,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,2BAA2B,CAAC;AAItD,MAAM,WAAW,KAAK;IACpB,IAAI,CAAC,EAAE,IAAI,GAAG,IAAI,CAAC;IACnB,OAAO,CAAC,EAAE,OAAO,GAAG,IAAI,CAAC;IACzB,QAAQ,EAAE,CAAC,IAAI,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,UAAU,CAAA;KAAE,KAAK,IAAI,CAAC;IAC9E,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAC;IACtB,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AAuGD,QAAA,MAAM,QAAQ,2CAAwC,CAAC;AACvD,KAAK,QAAQ,GAAG,UAAU,CAAC,OAAO,QAAQ,CAAC,CAAC;AAC5C,eAAe,QAAQ,CAAC"}
@@ -3,15 +3,18 @@
3
3
  * UserMenu - User profile menu dropdown
4
4
  * refactored for Material 3
5
5
  *
6
- * Accessibility:
6
+ * Accessibility (WAI-ARIA menu-button pattern):
7
7
  * - Proper ARIA attributes for menu state
8
- * - Keyboard navigation (Escape to close)
9
- * - Click outside to close
10
- * - Focus management
8
+ * - Roving tabindex over the menu items
9
+ * - Arrow Up/Down, Home/End to move between items; Escape closes + refocuses
10
+ * the trigger; Tab closes; click-outside dismisses
11
+ * - Focus management (first item focused on open, trigger refocused on close)
11
12
  */
13
+
12
14
  import type { Profile } from '@happyvertical/smrt-profiles';
13
15
  import { ripple } from '@happyvertical/smrt-ui';
14
16
  import { useI18n } from '@happyvertical/smrt-ui/i18n';
17
+ import { tick } from 'svelte';
15
18
  import { M } from '../i18n.js';
16
19
 
17
20
  const { t } = useI18n();
@@ -46,26 +49,92 @@ const userEmail = $derived(profile?.email ?? user?.email);
46
49
 
47
50
  let open = $state(false);
48
51
  let triggerButton: HTMLButtonElement;
52
+ let itemEls = $state<Array<HTMLAnchorElement | null>>([]);
49
53
  const instanceId = $props.id();
50
54
  const menuId = `user-menu-${instanceId}`;
51
55
 
56
+ /** Focus a menu item by index, wrapping out-of-range values. */
57
+ function focusItem(index: number) {
58
+ const items = itemEls.filter((el): el is HTMLAnchorElement => el != null);
59
+ if (items.length === 0) return;
60
+ const wrapped = (index + items.length) % items.length;
61
+ items[wrapped]?.focus();
62
+ }
63
+
64
+ async function openMenu(focusLast = false) {
65
+ open = true;
66
+ await tick();
67
+ focusItem(focusLast ? -1 : 0);
68
+ }
69
+
52
70
  function toggle() {
53
- open = !open;
54
- if (!open && triggerButton) {
55
- triggerButton.focus();
71
+ if (open) {
72
+ close();
73
+ } else {
74
+ void openMenu();
56
75
  }
57
76
  }
58
77
 
59
- function close() {
78
+ function close(refocusTrigger = true) {
60
79
  if (open) {
61
80
  open = false;
62
- // Return focus to trigger button
63
- triggerButton?.focus();
81
+ if (refocusTrigger) {
82
+ // Return focus to trigger button
83
+ triggerButton?.focus();
84
+ }
85
+ }
86
+ }
87
+
88
+ /** Roving-focus navigation inside the open menu. */
89
+ function handleMenuKeydown(event: KeyboardEvent) {
90
+ const items = itemEls.filter((el): el is HTMLAnchorElement => el != null);
91
+ // `document.activeElement` is `Element | null`; the cast satisfies indexOf's
92
+ // arg type (reference comparison is null-safe — a null active element yields
93
+ // -1). Kept as indexOf (not findIndex) so the biome formatter can't rewrite it.
94
+ const current = items.indexOf(document.activeElement as HTMLAnchorElement);
95
+
96
+ switch (event.key) {
97
+ case 'ArrowDown':
98
+ event.preventDefault();
99
+ focusItem(current + 1);
100
+ break;
101
+ case 'ArrowUp':
102
+ event.preventDefault();
103
+ focusItem(current - 1);
104
+ break;
105
+ case 'Home':
106
+ event.preventDefault();
107
+ focusItem(0);
108
+ break;
109
+ case 'End':
110
+ event.preventDefault();
111
+ focusItem(-1);
112
+ break;
113
+ case 'Escape':
114
+ event.preventDefault();
115
+ close();
116
+ break;
117
+ case 'Tab':
118
+ // Let focus leave naturally but collapse the menu.
119
+ close(false);
120
+ break;
121
+ }
122
+ }
123
+
124
+ /** Open the menu from the trigger via keyboard (ArrowDown/Up/Enter/Space). */
125
+ function handleTriggerKeydown(event: KeyboardEvent) {
126
+ if (open) return;
127
+ if (event.key === 'ArrowDown' || event.key === 'Enter' || event.key === ' ') {
128
+ event.preventDefault();
129
+ void openMenu();
130
+ } else if (event.key === 'ArrowUp') {
131
+ event.preventDefault();
132
+ void openMenu(true);
64
133
  }
65
134
  }
66
135
 
67
136
  /**
68
- * Handle keyboard navigation
137
+ * Handle window-level Escape so it works even if focus has left the menu.
69
138
  */
70
139
  function handleKeydown(event: KeyboardEvent) {
71
140
  if (event.key === 'Escape' && open) {
@@ -81,7 +150,7 @@ function handleClickOutside(event: MouseEvent) {
81
150
  const target = event.target as Node;
82
151
  const menu = document.getElementById(menuId);
83
152
  if (menu && !menu.contains(target) && !triggerButton.contains(target)) {
84
- close();
153
+ close(false);
85
154
  }
86
155
  }
87
156
 
@@ -107,6 +176,7 @@ function getInitials(name: string): string {
107
176
  id="{menuId}-trigger"
108
177
  class="user-menu-trigger"
109
178
  onclick={toggle}
179
+ onkeydown={handleTriggerKeydown}
110
180
  type="button"
111
181
  use:ripple
112
182
  aria-haspopup="menu"
@@ -128,8 +198,10 @@ function getInitials(name: string): string {
128
198
  id="{menuId}-dropdown"
129
199
  class="dropdown"
130
200
  role="menu"
201
+ tabindex={-1}
131
202
  aria-orientation="vertical"
132
203
  aria-labelledby="{menuId}-trigger"
204
+ onkeydown={handleMenuKeydown}
133
205
  >
134
206
  {#if userEmail}
135
207
  <div class="user-info" role="none">
@@ -138,11 +210,12 @@ function getInitials(name: string): string {
138
210
  </div>
139
211
  <hr class="divider" />
140
212
  {/if}
141
-
142
- <a
143
- href={profileUrl}
144
- class="dropdown-item"
145
- onclick={close}
213
+
214
+ <a
215
+ bind:this={itemEls[0]}
216
+ href={profileUrl}
217
+ class="dropdown-item"
218
+ onclick={() => close(false)}
146
219
  use:ripple
147
220
  role="menuitem"
148
221
  tabindex="-1"
@@ -152,10 +225,11 @@ function getInitials(name: string): string {
152
225
  </svg>
153
226
  Profile
154
227
  </a>
155
- <a
156
- href={settingsUrl}
157
- class="dropdown-item"
158
- onclick={close}
228
+ <a
229
+ bind:this={itemEls[1]}
230
+ href={settingsUrl}
231
+ class="dropdown-item"
232
+ onclick={() => close(false)}
159
233
  use:ripple
160
234
  role="menuitem"
161
235
  tabindex="-1"
@@ -166,10 +240,11 @@ function getInitials(name: string): string {
166
240
  Settings
167
241
  </a>
168
242
  <hr class="divider" />
169
- <a
170
- href={signoutUrl}
171
- class="dropdown-item danger"
172
- onclick={close}
243
+ <a
244
+ bind:this={itemEls[2]}
245
+ href={signoutUrl}
246
+ class="dropdown-item danger"
247
+ onclick={() => close(false)}
173
248
  use:ripple
174
249
  role="menuitem"
175
250
  tabindex="-1"
@@ -2,11 +2,12 @@
2
2
  * UserMenu - User profile menu dropdown
3
3
  * refactored for Material 3
4
4
  *
5
- * Accessibility:
5
+ * Accessibility (WAI-ARIA menu-button pattern):
6
6
  * - Proper ARIA attributes for menu state
7
- * - Keyboard navigation (Escape to close)
8
- * - Click outside to close
9
- * - Focus management
7
+ * - Roving tabindex over the menu items
8
+ * - Arrow Up/Down, Home/End to move between items; Escape closes + refocuses
9
+ * the trigger; Tab closes; click-outside dismisses
10
+ * - Focus management (first item focused on open, trigger refocused on close)
10
11
  */
11
12
  import type { Profile } from '@happyvertical/smrt-profiles';
12
13
  /** Props for the UserMenu component */
@@ -1 +1 @@
1
- {"version":3,"file":"UserMenu.svelte.d.ts","sourceRoot":"","sources":["../../../src/svelte/components/UserMenu.svelte.ts"],"names":[],"mappings":"AAGA;;;;;;;;;GASG;AACH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,8BAA8B,CAAC;AAM5D,uCAAuC;AACvC,MAAM,WAAW,KAAK;IACpB,+BAA+B;IAC/B,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,8DAA8D;IAC9D,IAAI,CAAC,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IACxC,uBAAuB;IACvB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,2BAA2B;IAC3B,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,4BAA4B;IAC5B,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,2CAA2C;IAC3C,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AA+HD,QAAA,MAAM,QAAQ,2CAAwC,CAAC;AACvD,KAAK,QAAQ,GAAG,UAAU,CAAC,OAAO,QAAQ,CAAC,CAAC;AAC5C,eAAe,QAAQ,CAAC"}
1
+ {"version":3,"file":"UserMenu.svelte.d.ts","sourceRoot":"","sources":["../../../src/svelte/components/UserMenu.svelte.ts"],"names":[],"mappings":"AAGA;;;;;;;;;;GAUG;AACH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,8BAA8B,CAAC;AAO5D,uCAAuC;AACvC,MAAM,WAAW,KAAK;IACpB,+BAA+B;IAC/B,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,8DAA8D;IAC9D,IAAI,CAAC,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IACxC,uBAAuB;IACvB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,2BAA2B;IAC3B,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,4BAA4B;IAC5B,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,2CAA2C;IAC3C,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAmMD,QAAA,MAAM,QAAQ,2CAAwC,CAAC;AACvD,KAAK,QAAQ,GAAG,UAAU,CAAC,OAAO,QAAQ,CAAC,CAAC;AAC5C,eAAe,QAAQ,CAAC"}
@@ -51,4 +51,15 @@ describe('InviteUserModal', () => {
51
51
  await userEvent.click(screen.getByRole('button', { name: 'Cancel' }));
52
52
  expect(onclose).toHaveBeenCalledTimes(1);
53
53
  });
54
+ // Regression for #1399 blocker #2: the hand-rolled dialog had no focus
55
+ // trap/restore. Adopting the smrt-ui Modal renders a native <dialog> (top
56
+ // layer + trap + inert), which supplies the trap and Escape handling the
57
+ // previous markup lacked.
58
+ it('renders inside a native <dialog> for the focus trap', () => {
59
+ const { container } = render(InviteUserModal, { props: baseProps() });
60
+ const dialog = container.querySelector('dialog');
61
+ expect(dialog).not.toBeNull();
62
+ // The form lives inside the dialog, so focus is trapped within it.
63
+ expect(dialog?.querySelector('form')).not.toBeNull();
64
+ });
54
65
  });
@@ -35,4 +35,49 @@ describe('UserMenu', () => {
35
35
  expect(screen.getByRole('menu')).toBeInTheDocument();
36
36
  await expectNoA11yViolations(container);
37
37
  });
38
+ // Regression for #1399 blocker #1: the menu items were keyboard-unreachable
39
+ // (role="menu" with no roving tabindex / arrow navigation). These assert the
40
+ // WAI-ARIA menu-button keyboard contract end to end.
41
+ const kbProps = {
42
+ user: { name: 'Ada Lovelace', email: 'ada@example.com' },
43
+ };
44
+ it('opens from the trigger and focuses the first item on ArrowDown', async () => {
45
+ render(UserMenu, { props: kbProps });
46
+ screen.getByRole('button', { name: 'User menu' }).focus();
47
+ await userEvent.keyboard('{ArrowDown}');
48
+ const items = screen.getAllByRole('menuitem');
49
+ expect(items).toHaveLength(3);
50
+ expect(document.activeElement).toBe(items[0]);
51
+ });
52
+ it('moves roving focus with ArrowDown/ArrowUp and wraps at the ends', async () => {
53
+ render(UserMenu, { props: kbProps });
54
+ screen.getByRole('button', { name: 'User menu' }).focus();
55
+ await userEvent.keyboard('{ArrowDown}');
56
+ const items = screen.getAllByRole('menuitem');
57
+ await userEvent.keyboard('{ArrowDown}');
58
+ expect(document.activeElement).toBe(items[1]);
59
+ // From the first item, ArrowUp wraps to the last.
60
+ await userEvent.keyboard('{ArrowUp}{ArrowUp}');
61
+ expect(document.activeElement).toBe(items[items.length - 1]);
62
+ });
63
+ it('jumps to the last item with End and the first with Home', async () => {
64
+ render(UserMenu, { props: kbProps });
65
+ screen.getByRole('button', { name: 'User menu' }).focus();
66
+ await userEvent.keyboard('{ArrowDown}');
67
+ const items = screen.getAllByRole('menuitem');
68
+ await userEvent.keyboard('{End}');
69
+ expect(document.activeElement).toBe(items[items.length - 1]);
70
+ await userEvent.keyboard('{Home}');
71
+ expect(document.activeElement).toBe(items[0]);
72
+ });
73
+ it('closes on Escape and restores focus to the trigger', async () => {
74
+ render(UserMenu, { props: kbProps });
75
+ const trigger = screen.getByRole('button', { name: 'User menu' });
76
+ trigger.focus();
77
+ await userEvent.keyboard('{ArrowDown}');
78
+ expect(screen.queryByRole('menu')).not.toBeNull();
79
+ await userEvent.keyboard('{Escape}');
80
+ expect(screen.queryByRole('menu')).toBeNull();
81
+ expect(document.activeElement).toBe(trigger);
82
+ });
38
83
  });
@@ -0,0 +1,36 @@
1
+ import { UserStatus } from '@happyvertical/smrt-types';
2
+ import { render, screen } from '@happyvertical/smrt-vitest/svelte';
3
+ import { describe, expect, it, vi } from 'vitest';
4
+ import UserCard from '../UserCard.svelte';
5
+ import UserForm from '../UserForm.svelte';
6
+ const profile = { name: 'Ada Lovelace', email: 'ada@example.com' };
7
+ const user = { email: 'ada@example.com', status: UserStatus.INACTIVE };
8
+ describe('UserForm status options', () => {
9
+ it('offers exactly the UserStatus values — including inactive, never deactivated', () => {
10
+ render(UserForm, { props: { onsubmit: vi.fn() } });
11
+ const select = screen.getByLabelText('Status', {
12
+ hidden: true,
13
+ });
14
+ const optionValues = Array.from(select.options).map((o) => o.value);
15
+ expect(optionValues).toEqual(Object.values(UserStatus));
16
+ expect(optionValues).toContain('inactive');
17
+ expect(optionValues).not.toContain('deactivated');
18
+ });
19
+ });
20
+ describe('UserCard status styling', () => {
21
+ it('styles an inactive user instead of leaving it unstyled', () => {
22
+ render(UserCard, {
23
+ props: { user, profile, status: 'inactive' },
24
+ });
25
+ const badge = screen.getByText('inactive', { hidden: true });
26
+ expect(badge.className).toContain('status-disabled');
27
+ });
28
+ it('does not recognise the bogus deactivated value', () => {
29
+ render(UserCard, {
30
+ props: { user, profile, status: 'deactivated' },
31
+ });
32
+ const badge = screen.getByText('deactivated', { hidden: true });
33
+ // No status-* style class is applied for a non-enum value.
34
+ expect(badge.className).not.toContain('status-disabled');
35
+ });
36
+ });