@happyvertical/smrt-users 0.31.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.
- package/dist/chunks/{TerminalAuthService-DoAMQ_yn.js → TerminalAuthService-DsQBk1Hc.js} +161 -71
- package/dist/chunks/TerminalAuthService-DsQBk1Hc.js.map +1 -0
- package/dist/chunks/{index-DkoYIvIu.js → index-Cp33Tyha.js} +10 -10
- package/dist/chunks/{index-DkoYIvIu.js.map → index-Cp33Tyha.js.map} +1 -1
- package/dist/collections/GroupMemberCollection.d.ts +9 -0
- package/dist/collections/GroupMemberCollection.d.ts.map +1 -1
- package/dist/collections/SessionCollection.d.ts.map +1 -1
- package/dist/index.js +38 -100
- package/dist/index.js.map +1 -1
- package/dist/manifest.json +2 -2
- package/dist/smrt-knowledge.json +4 -4
- package/dist/svelte/components/InviteUserModal.svelte +72 -169
- package/dist/svelte/components/InviteUserModal.svelte.d.ts.map +1 -1
- package/dist/svelte/components/UserCard.svelte +2 -1
- package/dist/svelte/components/UserCard.svelte.d.ts.map +1 -1
- package/dist/svelte/components/UserForm.svelte +11 -4
- package/dist/svelte/components/UserForm.svelte.d.ts.map +1 -1
- package/dist/svelte/components/UserMenu.svelte +100 -25
- package/dist/svelte/components/UserMenu.svelte.d.ts +5 -4
- package/dist/svelte/components/UserMenu.svelte.d.ts.map +1 -1
- package/dist/svelte/components/__tests__/InviteUserModal.test.js +11 -0
- package/dist/svelte/components/__tests__/UserMenu.test.js +45 -0
- package/dist/svelte/components/__tests__/UserStatus.test.js +36 -0
- package/dist/sveltekit/index.d.ts +7 -1
- package/dist/sveltekit/index.d.ts.map +1 -1
- package/dist/sveltekit.js +15 -8
- package/dist/sveltekit.js.map +1 -1
- package/package.json +8 -8
- 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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
</
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
<
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
|
|
232
|
-
|
|
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":"
|
|
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 '
|
|
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;
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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;
|
|
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
|
-
* -
|
|
9
|
-
* -
|
|
10
|
-
* -
|
|
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
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
63
|
-
|
|
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
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
* -
|
|
8
|
-
* -
|
|
9
|
-
* -
|
|
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
|
|
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
|
+
});
|