@ciromaciel/auth-react 1.3.0 → 1.4.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/README.md CHANGED
@@ -139,6 +139,33 @@ Building your own screen? `fetchRecentAccounts()`, `saveRecentAccount(method)` a
139
139
  `rememberAccount(email, method)`, `forgetAccount(email)` and `adoptRecentAccounts(list)` manage
140
140
  the local copy.
141
141
 
142
+ ### The account card
143
+
144
+ `<UserInformation />` is what opens from the footer of a sidebar: who is signed in, in which
145
+ organization and on which plan, then one list of actions with "Sair" last.
146
+
147
+ ```jsx
148
+ <UserInformation
149
+ user={user}
150
+ signOut={signOut}
151
+ onAccountClick={openAccount}
152
+ onBillingClick={openBilling}
153
+ items={[{ id: 'docs', label: 'Documentação', icon: IconFileText, onClick: openDocs }]}
154
+ plan={{ name: 'Pro', used: 3, limit: 9, unit: 'projetos', onClick: openPlans }}
155
+ organization={{ name: 'Acme', role: 'owner' }}
156
+ />
157
+ ```
158
+
159
+ - The title is the user's name, or the email when there is none — never both saying the same
160
+ thing.
161
+ - `items` are your app's own rows, passed as data (`{ id, label, icon, onClick }`), shown in their
162
+ own group after billing. The component takes no JSX, so every app's card keeps the same shape.
163
+ - `plan` and `organization` draw the context strip; leave them out and it is not drawn.
164
+ `limit: null` hides the usage bar.
165
+ - The footer reads "Protegido por Auth" with a "Termos" link to the same terms the sign-in
166
+ cites. `termsUrl` points it elsewhere, `termsUrl={null}` drops the link, and `branded={false}`
167
+ removes the footer.
168
+
142
169
  ## API
143
170
 
144
171
  ### Components
@@ -147,7 +174,7 @@ the local copy.
147
174
  | ------------------------- | ------------------------------------------------------------ |
148
175
  | `<SignIn />` | The whole way in: asks for the email, then takes the code |
149
176
  | `<UserProfile />` | User profile management modal |
150
- | `<UserInformation />` | User details and account menu |
177
+ | `<UserInformation />` | The account card: who, where, which plan, then the actions |
151
178
  | `<SocialButtons />` | Sign-in buttons for the providers the application enabled |
152
179
  | `<AuthCard />` | The card shell the screens are built on |
153
180
  | `<Wordmark />` | Application wordmark |
package/dist/index.esm.js CHANGED
@@ -3,9 +3,9 @@ import { useState, useCallback, useEffect, useMemo, createContext, useRef } from
3
3
  import { useShallow } from 'zustand/react/shallow';
4
4
  import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
5
5
  import { Navigate, Outlet, useNavigate } from 'react-router-dom';
6
- import { Modal, Stack, Text, Group, Image, Title, Paper, Anchor, NavLink, ActionIcon, Loader, Avatar, Button, Divider, TextInput, Alert, Center, Box, Collapse, FileButton, Tooltip, ThemeIcon, Badge, rem } from '@mantine/core';
6
+ import { Modal, Stack, Text, Group, Image, Title, Paper, Anchor, NavLink, ActionIcon, Loader, Avatar, Button, Divider, TextInput, Alert, Center, Box, Collapse, FileButton, Tooltip, ThemeIcon, Badge, UnstyledButton } from '@mantine/core';
7
7
  import { useForm } from '@mantine/form';
8
- import { IconX, IconArrowRight, IconBrandGoogle, IconArrowLeft, IconRefresh, IconAlertCircle, IconUser, IconPhoto, IconTrash, IconCheck, IconPencil, IconMail, IconShield, IconDevices, IconDeviceMobile, IconLogout, IconUserCircle, IconSettings, IconCreditCard, IconShieldCheck } from '@tabler/icons-react';
8
+ import { IconX, IconArrowRight, IconBrandGoogle, IconArrowLeft, IconRefresh, IconAlertCircle, IconUser, IconPhoto, IconTrash, IconCheck, IconPencil, IconMail, IconShield, IconDevices, IconDeviceMobile, IconLogout, IconUserCircle, IconBuilding, IconCreditCard } from '@tabler/icons-react';
9
9
 
10
10
  /**
11
11
  * The accounts that already signed in on this browser.
@@ -2533,17 +2533,13 @@ function Wordmark({
2533
2533
  });
2534
2534
  }
2535
2535
 
2536
+ // Where the terms live. Hardcoded on purpose, like `API_BASE` in authSdk.js:
2537
+ // this ships inside the published bundle, and every screen of the seven panels
2538
+ // that links to the terms — the sign-in notice and the account card's footer —
2539
+ // must point at the same document. `termsUrl` overrides it for whoever hosts
2540
+ // their own.
2536
2541
  const TERMS_URL = 'https://myinfrastructure.click/legal/terms';
2537
2542
 
2538
- /**
2539
- * The terms notice under the first step.
2540
- *
2541
- * It sits on step ONE and nowhere else: the account is born on the first
2542
- * `verify` and there is no separate sign-up screen to put it on — this form IS
2543
- * the sign-up for whoever has never entered. On step two the person already
2544
- * agreed by asking for the code; repeating it there would only push the code
2545
- * field down.
2546
- */
2547
2543
  function TermsNotice({
2548
2544
  url,
2549
2545
  text,
@@ -3982,150 +3978,377 @@ function UserProfile({
3982
3978
  });
3983
3979
  }
3984
3980
 
3981
+ const ROLE_LABELS = {
3982
+ owner: 'Dono',
3983
+ admin: 'Administrador',
3984
+ member: 'Membro'
3985
+ };
3986
+
3987
+ /**
3988
+ * @typedef {Object} UserInformationItem
3989
+ * @property {string} [id] - Stable key
3990
+ * @property {string} label - The row's text
3991
+ * @property {Function} [icon] - A Tabler icon component
3992
+ * @property {Function} onClick
3993
+ *
3994
+ * @typedef {Object} UserInformationPlan
3995
+ * @property {string} [name] - "Pro", "Starter"… Shown as the badge
3996
+ * @property {number} [used] - How many of the plan's resources are in use
3997
+ * @property {number|null} [limit] - The plan's ceiling; `null` hides the meter
3998
+ * @property {string} [unit] - What is counted, in the plural: "projetos"
3999
+ * @property {string} [actionLabel='Ver planos']
4000
+ * @property {Function} [onClick] - Opens the plans
4001
+ *
4002
+ * @typedef {Object} UserInformationOrganization
4003
+ * @property {string} name
4004
+ * @property {string} [role] - `owner`, `admin`, `member`, or already a label
4005
+ */
4006
+
4007
+ /**
4008
+ * @param {Object} props
4009
+ * @param {Object} props.user - The signed-in user (`name`, `email`, `image`)
4010
+ * @param {Function} props.signOut
4011
+ * @param {Function} [props.onAccountClick]
4012
+ * @param {Function} [props.onBillingClick]
4013
+ * @param {UserInformationItem[]} [props.items] - The panel's own rows, shown in their own group
4014
+ * @param {UserInformationPlan} [props.plan] - The plan strip; omitted, the strip is not drawn
4015
+ * @param {UserInformationOrganization} [props.organization] - Where the person is acting
4016
+ * @param {boolean} [props.branded=true] - The footer: "Protegido por Auth" and the terms link
4017
+ * @param {string|null} [props.termsUrl] - Where "Termos" points; `null` removes the link
4018
+ * @param {string} [props.termsLabel='Termos']
4019
+ * @param {string} [props.accountLabel='Conta']
4020
+ * @param {string} [props.billingLabel='Assinatura']
4021
+ * @param {string} [props.signOutLabel='Sair']
4022
+ */
3985
4023
  function UserInformation({
3986
4024
  user,
3987
4025
  signOut,
3988
4026
  onAccountClick,
3989
4027
  onBillingClick,
4028
+ items = [],
4029
+ plan,
4030
+ organization,
4031
+ branded = true,
4032
+ termsUrl = TERMS_URL,
4033
+ termsLabel = 'Termos',
3990
4034
  accountLabel = 'Conta',
3991
4035
  billingLabel = 'Assinatura',
4036
+ signOutLabel = 'Sair',
4037
+ // Kept so existing calls keep working. The card has one density now: the
4038
+ // popover's. `padded={false}` still removes the outer padding.
3992
4039
  padded = true,
3993
- size = 'sm',
4040
+ size,
4041
+ // eslint-disable-line no-unused-vars -- accepted and ignored, see above
3994
4042
  style,
3995
4043
  ...others
3996
4044
  }) {
3997
4045
  if (!user) return null;
4046
+ const name = (user.fullName || user.name || '').trim();
4047
+ const email = (user.primaryEmailAddress || user.email || '').trim();
3998
4048
 
3999
- // Size mappings
4000
- const avatarSizeMap = {
4001
- sm: 32,
4002
- md: 40,
4003
- lg: 48
4004
- };
4005
- const fontSizeTitleMap = {
4006
- sm: 'sm',
4007
- md: 'md',
4008
- lg: 'lg'
4009
- };
4010
- const fontSizeEmailMap = {
4011
- sm: '10px',
4012
- md: 'xs',
4013
- lg: 'sm'
4014
- };
4015
- const btnSizeMap = {
4016
- sm: 'xs',
4017
- md: 'sm',
4018
- lg: 'md'
4019
- };
4020
- const gapMap = {
4021
- sm: 'xs',
4022
- md: 'sm',
4023
- lg: 'md'
4024
- };
4025
- const widthMap = {
4026
- sm: 280,
4027
- md: 320,
4028
- lg: 400
4029
- };
4030
- const name = user.fullName || user.name || 'User';
4031
- const email = user.primaryEmailAddress || user.email || '';
4032
- const initials = name.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2);
4033
- return /*#__PURE__*/jsx(Box, {
4034
- p: padded ? gapMap[size] : 0,
4035
- w: widthMap[size],
4049
+ /*
4050
+ * A name equal to the email's local part is not a name: it is what an
4051
+ * account born from a code gets by default, and showing it on top of the
4052
+ * email repeats the same fact. It counts as "no name".
4053
+ */
4054
+ const hasRealName = Boolean(name) && name.toLowerCase() !== email.split('@')[0].toLowerCase() && name.toLowerCase() !== email.toLowerCase();
4055
+ const title = hasRealName ? name : email || name;
4056
+ const initials = hasRealName ? name.split(/\s+/).map(part => part[0]).join('').slice(0, 2).toUpperCase() : (email || name).charAt(0).toUpperCase();
4057
+ const baseRows = [onAccountClick && {
4058
+ id: 'account',
4059
+ label: accountLabel,
4060
+ icon: IconUser,
4061
+ onClick: onAccountClick
4062
+ }, onBillingClick && {
4063
+ id: 'billing',
4064
+ label: billingLabel,
4065
+ icon: IconCreditCard,
4066
+ onClick: onBillingClick
4067
+ }].filter(Boolean);
4068
+ const panelRows = items.filter(item => item && item.label && typeof item.onClick === 'function');
4069
+ return /*#__PURE__*/jsxs(Box, {
4070
+ w: 288,
4071
+ maw: "100%",
4036
4072
  style: style,
4037
4073
  ...others,
4038
- children: /*#__PURE__*/jsxs(Stack, {
4039
- gap: gapMap[size],
4040
- children: [/*#__PURE__*/jsxs(Group, {
4041
- wrap: "nowrap",
4042
- gap: "xs",
4043
- children: [/*#__PURE__*/jsx(Avatar, {
4044
- src: user.imageUrl || user.image,
4045
- size: avatarSizeMap[size],
4046
- radius: "xl",
4047
- bg: "gray.1",
4048
- c: "gray.6",
4049
- styles: {
4050
- placeholder: {
4051
- fontSize: rem(avatarSizeMap[size] / 2.2),
4052
- fontWeight: 600
4053
- }
4074
+ children: [/*#__PURE__*/jsxs(Group, {
4075
+ wrap: "nowrap",
4076
+ gap: 10,
4077
+ px: padded ? 16 : 0,
4078
+ py: 14,
4079
+ children: [/*#__PURE__*/jsx(Avatar, {
4080
+ src: user.imageUrl || user.image,
4081
+ alt: "",
4082
+ size: 32,
4083
+ radius: 0,
4084
+ color: "gray.9",
4085
+ variant: "filled",
4086
+ styles: {
4087
+ root: {
4088
+ borderRadius: 0
4089
+ },
4090
+ placeholder: {
4091
+ fontSize: 12,
4092
+ fontWeight: 800
4093
+ }
4094
+ },
4095
+ children: initials
4096
+ }), /*#__PURE__*/jsxs(Box, {
4097
+ style: {
4098
+ flex: 1,
4099
+ minWidth: 0
4100
+ },
4101
+ children: [/*#__PURE__*/jsx(Text, {
4102
+ fz: 13,
4103
+ fw: 800,
4104
+ c: "gray.9",
4105
+ lh: 1.3,
4106
+ truncate: "end",
4107
+ children: title
4108
+ }), hasRealName && email && /*#__PURE__*/jsx(Text, {
4109
+ fz: 12,
4110
+ fw: 500,
4111
+ c: "gray.5",
4112
+ lh: 1.4,
4113
+ truncate: "end",
4114
+ children: email
4115
+ })]
4116
+ })]
4117
+ }), (organization?.name || plan) && /*#__PURE__*/jsx(ContextStrip, {
4118
+ organization: organization,
4119
+ plan: plan,
4120
+ padded: padded
4121
+ }), /*#__PURE__*/jsxs(Box, {
4122
+ role: "menu",
4123
+ "aria-label": "Conta",
4124
+ onKeyDown: moveFocus,
4125
+ children: [baseRows.length > 0 && /*#__PURE__*/jsx(RowGroup, {
4126
+ rows: baseRows,
4127
+ hasDivider: !organization?.name && !plan
4128
+ }), panelRows.length > 0 && /*#__PURE__*/jsx(RowGroup, {
4129
+ rows: panelRows,
4130
+ hasDivider: true
4131
+ }), signOut && /*#__PURE__*/jsx(RowGroup, {
4132
+ rows: [{
4133
+ id: 'sign-out',
4134
+ label: signOutLabel,
4135
+ icon: IconLogout,
4136
+ onClick: signOut
4137
+ }],
4138
+ hasDivider: true
4139
+ })]
4140
+ }), branded && /*#__PURE__*/jsxs(Group, {
4141
+ justify: termsUrl ? 'space-between' : 'center',
4142
+ gap: 8,
4143
+ px: 16,
4144
+ py: 10,
4145
+ bg: "gray.1",
4146
+ style: {
4147
+ borderTop: '1px solid var(--mantine-color-gray-2)'
4148
+ },
4149
+ children: [/*#__PURE__*/jsxs(Text, {
4150
+ fz: 11,
4151
+ fw: 500,
4152
+ c: "gray.5",
4153
+ children: ["Protegido por", ' ', /*#__PURE__*/jsx(Text, {
4154
+ span: true,
4155
+ inherit: true,
4156
+ fw: 800,
4157
+ c: "gray.7",
4158
+ children: "Auth"
4159
+ })]
4160
+ }), termsUrl && /*#__PURE__*/jsx(Anchor, {
4161
+ href: termsUrl
4162
+ /*
4163
+ * A new tab, like the sign-in's notice: the card sits
4164
+ * over a working panel, and reading the terms must
4165
+ * not navigate away from it.
4166
+ */,
4167
+ target: "_blank",
4168
+ rel: "noopener noreferrer",
4169
+ fz: 11,
4170
+ fw: 500,
4171
+ c: "gray.5",
4172
+ underline: "always",
4173
+ children: termsLabel
4174
+ })]
4175
+ })]
4176
+ });
4177
+ }
4178
+
4179
+ /** The organization, the role and the plan's usage, on the alternate surface. */
4180
+ function ContextStrip({
4181
+ organization,
4182
+ plan,
4183
+ padded
4184
+ }) {
4185
+ const role = organization?.role ? ROLE_LABELS[organization.role] || organization.role : null;
4186
+ const hasMeter = plan && Number.isFinite(plan.used) && Number.isFinite(plan.limit) && plan.limit > 0;
4187
+ const ratio = hasMeter ? Math.min(1, Math.max(0, plan.used / plan.limit)) : 0;
4188
+ return /*#__PURE__*/jsxs(Stack, {
4189
+ gap: 8,
4190
+ px: padded ? 16 : 0,
4191
+ py: 12,
4192
+ bg: "gray.1",
4193
+ style: {
4194
+ borderTop: '1px solid var(--mantine-color-gray-2)',
4195
+ borderBottom: '1px solid var(--mantine-color-gray-2)'
4196
+ },
4197
+ children: [(organization?.name || plan?.name) && /*#__PURE__*/jsxs(Group, {
4198
+ gap: 8,
4199
+ wrap: "nowrap",
4200
+ children: [organization?.name && /*#__PURE__*/jsxs(Fragment, {
4201
+ children: [/*#__PURE__*/jsx(IconBuilding, {
4202
+ size: 14,
4203
+ stroke: 1.5,
4204
+ style: {
4205
+ flex: 'none',
4206
+ color: 'var(--mantine-color-gray-5)'
4207
+ }
4208
+ }), /*#__PURE__*/jsx(Text, {
4209
+ fz: 12,
4210
+ fw: 800,
4211
+ c: "gray.9",
4212
+ truncate: "end",
4213
+ style: {
4214
+ minWidth: 0
4054
4215
  },
4055
- children: initials || 'CC'
4056
- }), /*#__PURE__*/jsxs(Box, {
4216
+ children: organization.name
4217
+ }), role && /*#__PURE__*/jsxs(Text, {
4218
+ fz: 12,
4219
+ fw: 500,
4220
+ c: "gray.5",
4057
4221
  style: {
4058
- flex: 1,
4059
- overflow: 'hidden'
4222
+ flex: 'none'
4060
4223
  },
4061
- children: [/*#__PURE__*/jsx(Text, {
4062
- size: fontSizeTitleMap[size],
4063
- fw: 700,
4064
- truncate: "end",
4065
- c: "dark.9",
4066
- lh: 1.1,
4067
- children: name
4068
- }), /*#__PURE__*/jsx(Text, {
4069
- size: fontSizeEmailMap[size],
4070
- c: "gray.5",
4071
- truncate: "end",
4072
- lh: 1.1,
4073
- children: email
4074
- })]
4075
- }), /*#__PURE__*/jsx(ActionIcon, {
4076
- size: btnSizeMap[size],
4077
- onClick: signOut,
4078
- children: /*#__PURE__*/jsx(IconLogout, {
4079
- size: 16,
4080
- stroke: 1.5
4081
- })
4082
- })]
4083
- }), /*#__PURE__*/jsxs(Group, {
4084
- grow: true,
4085
- children: [/*#__PURE__*/jsx(Button, {
4086
- variant: "default",
4087
- size: btnSizeMap[size],
4088
- leftSection: /*#__PURE__*/jsx(IconSettings, {
4089
- size: 16,
4090
- stroke: 1.5
4091
- }),
4092
- onClick: onAccountClick,
4093
- children: accountLabel
4094
- }), /*#__PURE__*/jsx(Button, {
4095
- variant: "default",
4096
- size: btnSizeMap[size],
4097
- leftSection: /*#__PURE__*/jsx(IconCreditCard, {
4098
- size: 16,
4099
- stroke: 1.5
4100
- }),
4101
- onClick: onBillingClick,
4102
- children: billingLabel
4224
+ children: ["\xB7 ", role]
4103
4225
  })]
4226
+ }), plan?.name && /*#__PURE__*/jsx(Text, {
4227
+ component: "span",
4228
+ fz: 10,
4229
+ fw: 800,
4230
+ tt: "uppercase",
4231
+ lts: "1.5px",
4232
+ c: "white",
4233
+ bg: "gray.9",
4234
+ px: 6,
4235
+ lh: 1.6,
4236
+ ml: "auto",
4237
+ style: {
4238
+ flex: 'none'
4239
+ },
4240
+ children: plan.name
4241
+ })]
4242
+ }), hasMeter && /*#__PURE__*/jsxs(Fragment, {
4243
+ children: [/*#__PURE__*/jsx(Box, {
4244
+ h: 4,
4245
+ bg: "gray.2",
4246
+ role: "meter",
4247
+ "aria-valuemin": 0,
4248
+ "aria-valuemax": plan.limit,
4249
+ "aria-valuenow": plan.used,
4250
+ "aria-label": plan.unit ? `${plan.unit} em uso` : 'Uso do plano',
4251
+ children: /*#__PURE__*/jsx(Box, {
4252
+ h: "100%",
4253
+ w: `${ratio * 100}%`,
4254
+ bg: "gray.9"
4255
+ })
4104
4256
  }), /*#__PURE__*/jsxs(Group, {
4105
- justify: "center",
4106
- gap: 4,
4107
- opacity: 0.3,
4257
+ justify: "space-between",
4258
+ gap: 8,
4259
+ wrap: "nowrap",
4108
4260
  children: [/*#__PURE__*/jsx(Text, {
4109
- size: "10px",
4261
+ fz: 12,
4262
+ fw: 500,
4110
4263
  c: "gray.6",
4111
- fw: 600,
4112
- children: "Secured by"
4113
- }), /*#__PURE__*/jsxs(Group, {
4114
- gap: 2,
4115
- children: [/*#__PURE__*/jsx(IconShieldCheck, {
4116
- size: 10,
4117
- stroke: 2
4118
- }), /*#__PURE__*/jsx(Text, {
4119
- size: "10px",
4120
- fw: 800,
4121
- c: "dark.9",
4122
- children: "Auth"
4123
- })]
4264
+ children: `${plan.used} de ${plan.limit}${plan.unit ? ` ${plan.unit}` : ''}`
4265
+ }), plan.onClick && /*#__PURE__*/jsx(Anchor, {
4266
+ component: "button",
4267
+ type: "button",
4268
+ fz: 12,
4269
+ fw: 700,
4270
+ c: "gray.9",
4271
+ underline: "always",
4272
+ onClick: plan.onClick,
4273
+ children: plan.actionLabel || 'Ver planos'
4124
4274
  })]
4125
4275
  })]
4126
- })
4276
+ })]
4277
+ });
4278
+ }
4279
+ function RowGroup({
4280
+ rows,
4281
+ hasDivider
4282
+ }) {
4283
+ return /*#__PURE__*/jsx(Stack, {
4284
+ gap: 0,
4285
+ p: 6,
4286
+ style: hasDivider ? {
4287
+ borderTop: '1px solid var(--mantine-color-gray-2)'
4288
+ } : undefined,
4289
+ children: rows.map(row => /*#__PURE__*/jsx(Row, {
4290
+ ...row
4291
+ }, row.id || row.label))
4127
4292
  });
4128
4293
  }
4294
+ function Row({
4295
+ label,
4296
+ icon: Icon,
4297
+ onClick
4298
+ }) {
4299
+ return /*#__PURE__*/jsxs(UnstyledButton, {
4300
+ role: "menuitem",
4301
+ onClick: onClick,
4302
+ px: 10,
4303
+ py: 7,
4304
+ w: "100%",
4305
+ style: {
4306
+ display: 'flex',
4307
+ alignItems: 'center',
4308
+ gap: 10,
4309
+ borderRadius: 0
4310
+ }
4311
+ /*
4312
+ * Hover and keyboard focus share the same surface: a row reached
4313
+ * with the arrows must look exactly like the one under the mouse.
4314
+ */,
4315
+ onMouseEnter: event => event.currentTarget.style.background = 'var(--mantine-color-gray-1)',
4316
+ onMouseLeave: event => event.currentTarget.style.background = 'transparent',
4317
+ onFocus: event => event.currentTarget.style.background = 'var(--mantine-color-gray-1)',
4318
+ onBlur: event => event.currentTarget.style.background = 'transparent',
4319
+ children: [Icon && /*#__PURE__*/jsx(Icon, {
4320
+ size: 16,
4321
+ stroke: 1.5,
4322
+ style: {
4323
+ flex: 'none',
4324
+ color: 'var(--mantine-color-gray-5)'
4325
+ }
4326
+ }), /*#__PURE__*/jsx(Text, {
4327
+ fz: 12,
4328
+ fw: 500,
4329
+ c: "gray.9",
4330
+ truncate: "end",
4331
+ children: label
4332
+ })]
4333
+ });
4334
+ }
4335
+
4336
+ /*
4337
+ * Arrow keys walk the rows, Home and End jump to the ends — the WAI-ARIA menu
4338
+ * pattern. Tab still leaves the card, so it never traps focus inside a popover
4339
+ * the panel owns.
4340
+ */
4341
+ function moveFocus(event) {
4342
+ const keys = ['ArrowDown', 'ArrowUp', 'Home', 'End'];
4343
+ if (!keys.includes(event.key)) return;
4344
+ const rows = Array.from(event.currentTarget.querySelectorAll('[role="menuitem"]'));
4345
+ if (rows.length === 0) return;
4346
+ event.preventDefault();
4347
+ const current = rows.indexOf(document.activeElement);
4348
+ const last = rows.length - 1;
4349
+ const next = event.key === 'Home' ? 0 : event.key === 'End' ? last : event.key === 'ArrowDown' ? current < last ? current + 1 : 0 : current > 0 ? current - 1 : last;
4350
+ rows[next].focus();
4351
+ }
4129
4352
 
4130
4353
  /**
4131
4354
  * Renderiza children apenas quando o usuário está autenticado