@zauru-sdk/components 6.10.0 → 6.11.0

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
@@ -3,6 +3,25 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ # [6.11.0](https://github.com/intuitiva/zauru-typescript-sdk/compare/@zauru-sdk/components@6.10.0...@zauru-sdk/components@6.11.0) (2026-10-05)
7
+
8
+
9
+ ### Bug Fixes
10
+
11
+ * asociar etiquetas del selector con su campo ([1626bbd](https://github.com/intuitiva/zauru-typescript-sdk/commit/1626bbdd58971c353bf03b07533a171a05ec8571))
12
+ * identificar controles de la matriz de permisos ([472c956](https://github.com/intuitiva/zauru-typescript-sdk/commit/472c9565f2dc167e8566706f58c27ca55c6c9d09))
13
+ * limitar altura de selecciones múltiples ([b0e578d](https://github.com/intuitiva/zauru-typescript-sdk/commit/b0e578df1d7ec2198abfd57515b6dc8409227c4d))
14
+
15
+
16
+ ### Features
17
+
18
+ * exportar gestión de acceso de empleados ([df97155](https://github.com/intuitiva/zauru-typescript-sdk/commit/df97155d1370cdb624cc3ceb3905111a42edf65f))
19
+ * exportar gestión de roles ([f0ee633](https://github.com/intuitiva/zauru-typescript-sdk/commit/f0ee63301583591b72faba93b8eab2d7ec8e735f))
20
+
21
+
22
+
23
+
24
+
6
25
  # [6.10.0](https://github.com/intuitiva/zauru-typescript-sdk/compare/@zauru-sdk/components@6.9.0...@zauru-sdk/components@6.10.0) (2026-10-05)
7
26
 
8
27
 
@@ -0,0 +1,36 @@
1
+ import type { EmployeeGraphQL, WebAppRowGraphQL, WebappEmployeeRole, WebappRole } from "@zauru-sdk/types";
2
+ import { type ReactNode } from "react";
3
+ export type WebappEmployeeAssignmentChange<TAssignment extends WebappEmployeeRole> = {
4
+ employee: EmployeeGraphQL;
5
+ assignment?: WebAppRowGraphQL<TAssignment>;
6
+ roleId: number | null;
7
+ };
8
+ export type WebappEmployeeAssignmentDetails<TAssignment extends WebappEmployeeRole> = {
9
+ employee: EmployeeGraphQL;
10
+ assignment?: WebAppRowGraphQL<TAssignment>;
11
+ disabled: boolean;
12
+ };
13
+ type Props<TAssignment extends WebappEmployeeRole> = {
14
+ employees: EmployeeGraphQL[];
15
+ roles: WebAppRowGraphQL<WebappRole>[];
16
+ assignments: WebAppRowGraphQL<TAssignment>[];
17
+ canAssign?: boolean;
18
+ loading?: boolean;
19
+ description?: string;
20
+ superAdminLabel?: string;
21
+ addEmployeeTitle?: string;
22
+ addEmployeeDescription?: string;
23
+ noEmployeesToAddText?: string;
24
+ noRolesText?: string;
25
+ assignmentDetailsColumn?: {
26
+ name: string;
27
+ minWidth?: string;
28
+ grow?: number;
29
+ };
30
+ renderAssignmentDetails?: (details: WebappEmployeeAssignmentDetails<TAssignment>) => ReactNode;
31
+ superAdminEmailSuffixes?: readonly string[];
32
+ onAssignRole: (change: WebappEmployeeAssignmentChange<TAssignment>) => void;
33
+ onAddEmployee: (employeeId: number, roleId: number) => void;
34
+ };
35
+ export declare function WebappEmployeeAccess<TAssignment extends WebappEmployeeRole = WebappEmployeeRole>({ employees, roles, assignments, canAssign, loading, description, superAdminLabel, addEmployeeTitle, addEmployeeDescription, noEmployeesToAddText, noRolesText, assignmentDetailsColumn, renderAssignmentDetails, superAdminEmailSuffixes, onAssignRole, onAddEmployee, }: Props<TAssignment>): import("react/jsx-runtime").JSX.Element;
36
+ export {};
@@ -0,0 +1,22 @@
1
+ import type { WebAppRowGraphQL, WebappRole } from "@zauru-sdk/types";
2
+ export type WebappRoleValues = {
3
+ name: string;
4
+ description: string;
5
+ };
6
+ type Props = {
7
+ roles: WebAppRowGraphQL<WebappRole>[];
8
+ canCreate?: boolean;
9
+ canEdit?: boolean;
10
+ canDelete?: boolean;
11
+ loading?: boolean;
12
+ createTitle?: string;
13
+ createButtonLabel?: string;
14
+ editTitle?: string;
15
+ deleteTitle?: string;
16
+ deleteDescription?: string;
17
+ onCreate: (values: WebappRoleValues) => void;
18
+ onUpdate: (roleId: number, values: WebappRoleValues) => void;
19
+ onDelete: (roleId: number) => void;
20
+ };
21
+ export declare function WebappRoleManagement({ roles, canCreate, canEdit, canDelete, loading, createTitle, createButtonLabel, editTitle, deleteTitle, deleteDescription, onCreate, onUpdate, onDelete, }: Props): import("react/jsx-runtime").JSX.Element | null;
22
+ export {};
@@ -0,0 +1,2 @@
1
+ export * from "./WebappEmployeeAccess.js";
2
+ export * from "./WebappRoleManagement.js";
@@ -0,0 +1,103 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { isSuperAdminEmail } from "@zauru-sdk/utils";
3
+ import { useMemo } from "react";
4
+ import { z } from "zod";
5
+ import { Button } from "../Buttons/Button.js";
6
+ import { ButtonSectionContainer } from "../Containers/ButtonSectionContainer.js";
7
+ import { DoubleFieldContainer } from "../Form/FieldContainer/DoubleFieldContainer.js";
8
+ import { ReactZodForm } from "../Form/ReactZodForm/index.js";
9
+ import { SelectField } from "../Form/SelectField/index.js";
10
+ import { ZauruTable } from "../Table/ZauruTable.js";
11
+ const addEmployeeSchema = z.object({
12
+ employee_id: z.coerce.number().min(1, "El empleado es requerido"),
13
+ role_id: z.coerce.number().min(1, "El rol es requerido"),
14
+ });
15
+ export function WebappEmployeeAccess({ employees, roles, assignments, canAssign = false, loading = false, description = "Solo aparecen quienes ya tienen acceso: rol asignado o super admin. Quitar el rol los saca de la lista.", superAdminLabel = "Super admin (todos los permisos)", addEmployeeTitle = "Agregar empleado", addEmployeeDescription = "Busca entre quienes aún no están en la lista y asígnales un rol.", noEmployeesToAddText = "No quedan empleados activos por agregar.", noRolesText = "Crea al menos un rol antes de agregar empleados.", assignmentDetailsColumn, renderAssignmentDetails, superAdminEmailSuffixes, onAssignRole, onAddEmployee, }) {
16
+ const roleOptions = useMemo(() => [
17
+ { value: "", label: "Sin rol" },
18
+ ...roles.map((role) => ({
19
+ value: String(role.id),
20
+ label: role.data.name,
21
+ })),
22
+ ], [roles]);
23
+ const addRoleOptions = useMemo(() => roles.map((role) => ({
24
+ value: String(role.id),
25
+ label: role.data.name,
26
+ })), [roles]);
27
+ const assignmentByEmployee = useMemo(() => {
28
+ const map = new Map();
29
+ for (const row of assignments) {
30
+ map.set(Number(row.data.employee_id), row);
31
+ }
32
+ return map;
33
+ }, [assignments]);
34
+ const listedEmployees = useMemo(() => employees
35
+ .filter((employee) => isSuperAdminEmail(employee.email, superAdminEmailSuffixes) ||
36
+ assignmentByEmployee.has(employee.id))
37
+ .sort((a, b) => a.name.localeCompare(b.name)), [assignmentByEmployee, employees, superAdminEmailSuffixes]);
38
+ const addEmployeeOptions = useMemo(() => {
39
+ const listedIds = new Set(listedEmployees.map((employee) => employee.id));
40
+ return employees
41
+ .filter((employee) => !listedIds.has(employee.id))
42
+ .sort((a, b) => a.name.localeCompare(b.name))
43
+ .map((employee) => ({
44
+ value: employee.id,
45
+ label: employee.email
46
+ ? `${employee.name} (${employee.email})`
47
+ : employee.name,
48
+ }));
49
+ }, [employees, listedEmployees]);
50
+ const columns = [
51
+ {
52
+ name: "Empleado",
53
+ sortable: true,
54
+ grow: 1,
55
+ minWidth: "12rem",
56
+ selector: (employee) => employee.name,
57
+ },
58
+ {
59
+ name: "Email",
60
+ sortable: true,
61
+ grow: 1,
62
+ minWidth: "14rem",
63
+ selector: (employee) => employee.email || "—",
64
+ },
65
+ {
66
+ name: "Rol",
67
+ minWidth: "14rem",
68
+ cell: (employee) => {
69
+ const assignment = assignmentByEmployee.get(employee.id);
70
+ const superAdmin = isSuperAdminEmail(employee.email, superAdminEmailSuffixes);
71
+ const currentRole = assignment
72
+ ? String(assignment.data.role_id)
73
+ : "";
74
+ return (_jsx("div", { className: "w-56 py-2", children: superAdmin ? (_jsx("p", { className: "text-sm text-gray-600", children: superAdminLabel })) : (_jsx(SelectField, { name: `role_${employee.id}`, options: roleOptions, defaultValue: roleOptions.find((option) => option.value === currentRole) ?? roleOptions[0], disabled: !canAssign || loading, onChange: (option) => {
75
+ const value = option?.value?.toString() ?? "";
76
+ onAssignRole({
77
+ employee,
78
+ assignment,
79
+ roleId: value ? Number(value) : null,
80
+ });
81
+ } })) }));
82
+ },
83
+ },
84
+ ...(assignmentDetailsColumn && renderAssignmentDetails
85
+ ? [
86
+ {
87
+ name: assignmentDetailsColumn.name,
88
+ minWidth: assignmentDetailsColumn.minWidth ?? "16rem",
89
+ grow: assignmentDetailsColumn.grow ?? 1,
90
+ cell: (employee) => {
91
+ const assignment = assignmentByEmployee.get(employee.id);
92
+ return renderAssignmentDetails({
93
+ employee,
94
+ assignment,
95
+ disabled: !canAssign || !assignment || loading,
96
+ });
97
+ },
98
+ },
99
+ ]
100
+ : []),
101
+ ];
102
+ return (_jsxs("div", { className: "mt-4 space-y-6", children: [_jsx("p", { className: "text-sm text-gray-600", children: description }), _jsx(ZauruTable, { columns: columns, data: listedEmployees, loading: loading, offlineSearch: ["name", "email"] }), canAssign ? (roles.length === 0 ? (_jsx("p", { className: "text-sm text-gray-600", role: "status", children: noRolesText })) : addEmployeeOptions.length > 0 ? (_jsx("div", { className: "rounded-md border border-gray-200 bg-white p-4", children: _jsxs(ReactZodForm, { schema: addEmployeeSchema, onSubmit: (values) => onAddEmployee(Number(values.employee_id), Number(values.role_id)), children: [_jsx("h2", { className: "text-base font-semibold text-gray-900", children: addEmployeeTitle }), _jsx("p", { className: "mb-4 mt-1 text-sm text-gray-600", children: addEmployeeDescription }), _jsxs(DoubleFieldContainer, { alignCenter: false, children: [_jsx(SelectField, { name: "employee_id", title: "Empleado", hint: "Escribe para filtrar por nombre o correo", options: addEmployeeOptions, required: true }), _jsx(SelectField, { name: "role_id", title: "Rol", options: addRoleOptions, required: true })] }), _jsx(ButtonSectionContainer, { children: _jsx(Button, { loading: loading, title: "Agregar" }) })] }) })) : (_jsx("p", { className: "text-sm text-gray-600", role: "status", children: noEmployeesToAddText }))) : null] }));
103
+ }
@@ -0,0 +1,77 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { PencilSvg, TrashSvg } from "@zauru-sdk/icons";
3
+ import { useSyncExternalStore } from "react";
4
+ import { z } from "zod";
5
+ import { Button } from "../Buttons/Button.js";
6
+ import { ButtonSectionContainer } from "../Containers/ButtonSectionContainer.js";
7
+ import { ReactZodForm } from "../Form/ReactZodForm/index.js";
8
+ import { TextField } from "../Form/TextField/index.js";
9
+ import { createModal } from "../Modal/Modal.js";
10
+ import { ZauruTable } from "../Table/ZauruTable.js";
11
+ const roleSchema = z.object({
12
+ name: z.string().min(1, "El nombre es requerido"),
13
+ description: z.string().optional(),
14
+ });
15
+ const subscribeToBrowser = () => () => undefined;
16
+ const getBrowserSnapshot = () => true;
17
+ const getServerSnapshot = () => false;
18
+ export function WebappRoleManagement({ roles, canCreate = false, canEdit = false, canDelete = false, loading = false, createTitle = "Crear rol", createButtonLabel = "Crear rol", editTitle = "Editar rol", deleteTitle = "¿Eliminar este rol?", deleteDescription = "No se puede eliminar si hay empleados asignados.", onCreate, onUpdate, onDelete, }) {
19
+ const isClient = useSyncExternalStore(subscribeToBrowser, getBrowserSnapshot, getServerSnapshot);
20
+ const editRole = async (role) => {
21
+ let name = role.data.name;
22
+ let description = role.data.description ?? "";
23
+ const response = await createModal({
24
+ title: editTitle,
25
+ okButtonText: "Guardar",
26
+ description: (_jsxs(_Fragment, { children: [_jsx(TextField, { title: "Nombre", name: "name", defaultValue: name, onChange: (value) => {
27
+ name = value;
28
+ } }), _jsx(TextField, { className: "mt-4", title: "Descripci\u00F3n", name: "description", defaultValue: description, onChange: (value) => {
29
+ description = value;
30
+ } })] })),
31
+ });
32
+ if (response === "OK") {
33
+ onUpdate(role.id, { name, description });
34
+ }
35
+ };
36
+ const confirmDelete = async (roleId) => {
37
+ const response = await createModal({
38
+ title: deleteTitle,
39
+ description: deleteDescription,
40
+ });
41
+ if (response === "OK") {
42
+ onDelete(roleId);
43
+ }
44
+ };
45
+ const columns = [
46
+ {
47
+ name: "Nombre",
48
+ sortable: true,
49
+ grow: 1,
50
+ selector: (role) => role.data.name,
51
+ },
52
+ {
53
+ name: "Descripción",
54
+ grow: 2,
55
+ selector: (role) => role.data.description || "—",
56
+ },
57
+ {
58
+ name: "Permisos",
59
+ width: "8%",
60
+ selector: (role) => role.data.permission_keys?.length ?? 0,
61
+ },
62
+ {
63
+ name: "",
64
+ grow: 0,
65
+ minWidth: "90px",
66
+ maxWidth: "90px",
67
+ cell: (role) => (_jsxs("div", { className: "flex gap-2", children: [canDelete ? (_jsx("button", { type: "button", title: "Eliminar", "aria-label": `Eliminar rol ${role.data.name}`, disabled: loading, className: "cursor-pointer rounded focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50", onClick: () => void confirmDelete(role.id), children: _jsx(TrashSvg, {}) })) : null, canEdit ? (_jsx("button", { type: "button", title: "Editar", "aria-label": `Editar rol ${role.data.name}`, disabled: loading, className: "cursor-pointer rounded focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50", onClick: () => void editRole(role), children: _jsx(PencilSvg, {}) })) : null] })),
68
+ },
69
+ ];
70
+ if (!isClient) {
71
+ return null;
72
+ }
73
+ return (_jsxs("div", { className: "mt-4 space-y-6", children: [_jsx(ZauruTable, { columns: columns, data: [...roles].sort((a, b) => a.data.name.localeCompare(b.data.name)), loading: loading, offlineSearch: ["data.name", "data.description"] }), canCreate ? (_jsx("div", { className: "rounded-md border border-gray-200 bg-white p-4", children: _jsxs(ReactZodForm, { schema: roleSchema, onSubmit: (values) => onCreate({
74
+ name: String(values.name ?? ""),
75
+ description: String(values.description ?? ""),
76
+ }), children: [_jsx("h2", { className: "mb-4 text-base font-semibold text-gray-900", children: createTitle }), _jsx(TextField, { name: "name", title: "Nombre del rol", required: true }), _jsx(TextField, { className: "mt-4", name: "description", title: "Descripci\u00F3n" }), _jsx(ButtonSectionContainer, { children: _jsx(Button, { loading: loading, title: createButtonLabel }) })] }) })) : null] }));
77
+ }
@@ -0,0 +1,2 @@
1
+ export * from "./WebappEmployeeAccess.js";
2
+ export * from "./WebappRoleManagement.js";
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { IdeaIconSVG } from "@zauru-sdk/icons";
3
- import { useEffect, useState, useRef, useCallback, } from "react";
3
+ import { useEffect, useState, useRef, useId, useCallback, } from "react";
4
4
  import { LoadingInputSkeleton } from "../../Skeletons/index.js";
5
5
  import { useFormContext } from "react-hook-form";
6
6
  // Simple comparison helper to check if two SelectFieldOption arrays contain the
@@ -30,6 +30,10 @@ export const SelectField = (props) => {
30
30
  const lastSyncedDefaultValues = useRef(defaultValueMulti.map((option) => option.value));
31
31
  const [isTabPressed, setIsTabPressed] = useState(false);
32
32
  const [isSearching, setIsSearching] = useState(false);
33
+ const generatedInputId = useId();
34
+ const inputId = id ?? (name ? `${name}-input` : generatedInputId);
35
+ const errorId = `${inputId}-error`;
36
+ const hintId = `${inputId}-hint`;
33
37
  // Use refs to store stable references to callbacks
34
38
  const onChangeRef = useRef(onChange);
35
39
  const onChangeMultiRef = useRef(onChangeMulti);
@@ -275,11 +279,13 @@ export const SelectField = (props) => {
275
279
  options,
276
280
  ]);
277
281
  if (loading) {
278
- return (_jsxs(_Fragment, { children: [title && (_jsx("label", { htmlFor: error ? `${name}-error` : `${name}-success`, className: `block text-sm font-medium text-${color}-700 dark:text-${color}-500`, children: title })), _jsx(LoadingInputSkeleton, {}), helpText && (_jsx("p", { className: `mt-2 italic text-sm text-${color}-500 dark:text-${color}-400`, children: helpText }))] }));
282
+ return (_jsxs(_Fragment, { children: [title && (_jsx("span", { className: `block text-sm font-medium text-${color}-700 dark:text-${color}-500`, children: title })), _jsx(LoadingInputSkeleton, {}), helpText && (_jsx("p", { className: `mt-2 italic text-sm text-${color}-500 dark:text-${color}-400`, children: helpText }))] }));
279
283
  }
280
- return (_jsxs("div", { className: `col-span-6 sm:col-span-3 ${className}`, ref: selectRef, children: [title && (_jsxs("label", { htmlFor: error ? `${name}-error` : `${name}-success`, className: `block text-sm font-medium ${color === "red"
284
+ return (_jsxs("div", { className: `col-span-6 sm:col-span-3 ${className}`, ref: selectRef, children: [title && (_jsxs("label", { htmlFor: inputId, className: `block text-sm font-medium ${color === "red"
281
285
  ? "text-red-700 dark:text-red-500"
282
- : "text-gray-700 dark:text-gray-500"}`, children: [title, required && _jsx("span", { className: "text-red-500", children: "*" })] })), _jsxs("div", { className: "relative", children: [_jsxs("div", { className: "flex items-center", children: [_jsx("input", { type: "text", id: id, value: inputValue, onFocus: () => setIsOpen(true), onKeyDown: handleKeyDown, readOnly: isReadOnly, disabled: disabled, className: `block w-full rounded-md ${bgColor} ${borderColor} ${textColor} shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm pr-10`, placeholder: isMulti ? "Select options..." : "Select an option...", autoComplete: "off", onChange: handleInputChange, onBlur: handleBlur, required: required }), isClearable && (value || valueMulti.length > 0) && (_jsx("button", { type: "button", onClick: handleClear, className: "absolute inset-y-0 right-0 pr-3 flex items-center", children: "\u00D7" })), helpText && (_jsx("div", { className: "flex items-center relative ml-3", children: _jsxs("div", { className: "relative cursor-pointer", onMouseEnter: () => setShowTooltip(true), onMouseLeave: () => setShowTooltip(false), children: [_jsx(IdeaIconSVG, {}), showTooltip && (_jsx("div", { className: "absolute -left-48 top-0 mt-8 p-2 bg-white border rounded shadow text-black z-50", children: helpText }))] }) }))] }), _jsx("input", { type: "hidden", ...(register ?? {}), name: name, value: isMulti
286
+ : "text-gray-700 dark:text-gray-500"}`, children: [title, required && _jsx("span", { className: "text-red-500", children: "*" })] })), _jsxs("div", { className: "relative", children: [_jsxs("div", { className: "flex items-center", children: [_jsx("input", { type: "text", id: inputId, value: inputValue, onFocus: () => setIsOpen(true), onKeyDown: handleKeyDown, readOnly: isReadOnly, disabled: disabled, className: `block w-full rounded-md ${bgColor} ${borderColor} ${textColor} shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm pr-10`, placeholder: isMulti ? "Select options..." : "Select an option...", autoComplete: "off", onChange: handleInputChange, onBlur: handleBlur, required: required, "aria-invalid": Boolean(error), "aria-describedby": error ? errorId : hint ? hintId : undefined }), isClearable && (value || valueMulti.length > 0) && (_jsx("button", { type: "button", onClick: handleClear, "aria-label": isMulti
287
+ ? "Limpiar todas las opciones seleccionadas"
288
+ : "Limpiar la opción seleccionada", className: "absolute inset-y-0 right-0 flex cursor-pointer items-center pr-3 text-gray-600 hover:text-gray-900 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2", children: _jsx("span", { "aria-hidden": "true", children: "\u00D7" }) })), helpText && (_jsx("div", { className: "flex items-center relative ml-3", children: _jsxs("div", { className: "relative cursor-pointer", onMouseEnter: () => setShowTooltip(true), onMouseLeave: () => setShowTooltip(false), children: [_jsx(IdeaIconSVG, {}), showTooltip && (_jsx("div", { className: "absolute -left-48 top-0 mt-8 p-2 bg-white border rounded shadow text-black z-50", children: helpText }))] }) }))] }), _jsx("input", { type: "hidden", ...(register ?? {}), name: name, value: isMulti
283
289
  ? valueMulti.map((v) => v.value).join(",")
284
290
  : value?.value || "" }), isOpen && !isReadOnly && (_jsx("ul", { ref: optionsRef, className: "absolute z-10 mt-1 w-full bg-white shadow-lg max-h-60 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm", children: filteredOptions.map((option, index) => {
285
291
  const isDisabled = Boolean(option.disabled);
@@ -294,5 +300,5 @@ export const SelectField = (props) => {
294
300
  : index === highlightedIndex
295
301
  ? "cursor-pointer text-black bg-sky-200"
296
302
  : "cursor-pointer text-gray-900"}`, onClick: () => !isDisabled && handleOptionClick(option), onMouseEnter: () => !isDisabled && setHighlightedIndex(index), onMouseLeave: () => setHighlightedIndex(-1), children: [option.label, isDisabled && disabledLabel && (_jsxs("span", { className: "ml-2 text-xs text-gray-400", children: ["(", disabledLabel, ")"] }))] }, `${option.value}-${index}`));
297
- }) }))] }), isMulti && valueMulti.length > 0 && (_jsx("div", { className: "mt-2 flex flex-wrap gap-2", children: valueMulti.map((option, index) => (_jsxs("span", { className: "bg-blue-100 text-blue-800 text-xs font-semibold mr-2 px-2.5 py-0.5 rounded", children: [option.label, _jsx("button", { type: "button", onClick: () => handleRemoveMultiValue(option), className: "ml-1 text-blue-600 hover:text-blue-800", children: "\u00D7" })] }, `${option.value}-${index}`))) })), error && (_jsxs("p", { className: `mt-2 text-sm text-${color}-600 dark:text-${color}-500`, children: [_jsx("span", { className: "font-medium", children: "Oops!" }), " ", error?.message?.toString() || "Error desconocido"] })), !error && hint && (_jsx("p", { className: `mt-2 italic text-sm text-${color}-500 dark:text-${color}-400`, children: hint }))] }));
303
+ }) }))] }), isMulti && valueMulti.length > 0 && (_jsx("ul", { className: "mt-2 flex max-h-20 flex-wrap gap-1.5 overflow-auto overscroll-contain rounded-md border border-blue-100 bg-blue-50/40 p-1.5", "aria-label": "Opciones seleccionadas", children: valueMulti.map((option, index) => (_jsxs("li", { className: "inline-flex max-w-full shrink-0 items-center rounded bg-blue-100 py-0.5 pl-2.5 pr-0.5 text-xs font-semibold text-blue-800", children: [_jsx("span", { className: "whitespace-nowrap", children: option.label }), _jsx("button", { type: "button", onClick: () => handleRemoveMultiValue(option), "aria-label": `Quitar ${option.label}`, className: "ml-1 inline-flex h-6 w-6 shrink-0 cursor-pointer items-center justify-center rounded text-blue-700 hover:bg-blue-200 hover:text-blue-900 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1", children: _jsx("span", { "aria-hidden": "true", children: "\u00D7" }) })] }, `${option.value}-${index}`))) })), error && (_jsxs("p", { id: errorId, className: `mt-2 text-sm text-${color}-600 dark:text-${color}-500`, children: [_jsx("span", { className: "font-medium", children: "Oops!" }), " ", error?.message?.toString() || "Error desconocido"] })), !error && hint && (_jsx("p", { id: hintId, className: `mt-2 italic text-sm text-${color}-500 dark:text-${color}-400`, children: hint }))] }));
298
304
  };
@@ -22,7 +22,7 @@ function GroupRows({ group, roles, sectionKeys, disabled, pendingCells, onToggle
22
22
  const granted = role.permissionKeys.includes(action.key);
23
23
  const cellKey = permissionMatrixCellKey(role.id, action.key);
24
24
  const pending = pendingCells?.has(cellKey) ?? false;
25
- return (_jsx("td", { className: "border-b border-l border-gray-100 px-3 py-2 text-center", children: _jsx("input", { type: "checkbox", className: checkboxClass, checked: granted, disabled: disabled || pending, "aria-label": `${role.name}: ${action.label}`, onChange: () => onToggle(role.id, action.key, !granted) }) }, role.id));
25
+ return (_jsx("td", { className: "border-b border-l border-gray-100 px-3 py-2 text-center", children: _jsx("input", { type: "checkbox", id: `permission-${role.id}-${action.key}`, name: `permission-${role.id}-${action.key}`, className: checkboxClass, checked: granted, disabled: disabled || pending, "aria-label": `${role.name}: ${action.label}`, onChange: () => onToggle(role.id, action.key, !granted) }) }, role.id));
26
26
  })] }, action.key))), onBulkSection ? (_jsxs("tr", { children: [_jsx("td", { className: "sticky left-0 z-10 border-b border-gray-200 bg-white px-3 py-1 text-xs text-gray-500", children: "Secci\u00F3n" }), roles.map((role) => {
27
27
  const sectionGranted = sectionKeys.every((key) => role.permissionKeys.includes(key));
28
28
  return (_jsx("td", { className: "border-b border-l border-gray-200 px-3 py-1 text-center", children: _jsx("button", { type: "button", className: "cursor-pointer text-xs font-medium text-indigo-700 hover:text-indigo-900 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 rounded disabled:cursor-not-allowed disabled:opacity-50", disabled: disabled, onClick: () => onBulkSection(role.id, group.id, sectionGranted ? "remove" : "add"), children: sectionGranted ? "Quitar sección" : "Marcar sección" }) }, role.id));
package/dist/esm/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from "./Alerts/index.js";
2
+ export * from "./AccessControl/index.js";
2
3
  export * from "./BlockUI/index.js";
3
4
  export * from "./Buttons/index.js";
4
5
  export * from "./Card/index.js";
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from "./Alerts/index.js";
2
+ export * from "./AccessControl/index.js";
2
3
  export * from "./BlockUI/index.js";
3
4
  export * from "./Buttons/index.js";
4
5
  export * from "./Card/index.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zauru-sdk/components",
3
- "version": "6.10.0",
3
+ "version": "6.11.0",
4
4
  "description": "Componentes reutilizables en las WebApps de Zauru.",
5
5
  "main": "./dist/esm/index.js",
6
6
  "module": "./dist/esm/index.js",
@@ -62,5 +62,5 @@
62
62
  "styled-components": "^5.3.5",
63
63
  "zod": "^3.23.8"
64
64
  },
65
- "gitHead": "a3c1f2af46acb59cf9fcb78ecb18d904e33c61e5"
65
+ "gitHead": "885c1d44afb4c5717ae823664b37ac48b30283db"
66
66
  }
@@ -0,0 +1,275 @@
1
+ import type {
2
+ EmployeeGraphQL,
3
+ SelectFieldOption,
4
+ WebAppRowGraphQL,
5
+ WebappEmployeeRole,
6
+ WebappRole,
7
+ } from "@zauru-sdk/types";
8
+ import { isSuperAdminEmail } from "@zauru-sdk/utils";
9
+ import { useMemo, type ReactNode } from "react";
10
+ import { z } from "zod";
11
+ import { Button } from "../Buttons/Button.js";
12
+ import { ButtonSectionContainer } from "../Containers/ButtonSectionContainer.js";
13
+ import { DoubleFieldContainer } from "../Form/FieldContainer/DoubleFieldContainer.js";
14
+ import { ReactZodForm } from "../Form/ReactZodForm/index.js";
15
+ import { SelectField } from "../Form/SelectField/index.js";
16
+ import { ZauruTable } from "../Table/ZauruTable.js";
17
+
18
+ const addEmployeeSchema = z.object({
19
+ employee_id: z.coerce.number().min(1, "El empleado es requerido"),
20
+ role_id: z.coerce.number().min(1, "El rol es requerido"),
21
+ });
22
+
23
+ export type WebappEmployeeAssignmentChange<
24
+ TAssignment extends WebappEmployeeRole,
25
+ > = {
26
+ employee: EmployeeGraphQL;
27
+ assignment?: WebAppRowGraphQL<TAssignment>;
28
+ roleId: number | null;
29
+ };
30
+
31
+ export type WebappEmployeeAssignmentDetails<
32
+ TAssignment extends WebappEmployeeRole,
33
+ > = {
34
+ employee: EmployeeGraphQL;
35
+ assignment?: WebAppRowGraphQL<TAssignment>;
36
+ disabled: boolean;
37
+ };
38
+
39
+ type Props<TAssignment extends WebappEmployeeRole> = {
40
+ employees: EmployeeGraphQL[];
41
+ roles: WebAppRowGraphQL<WebappRole>[];
42
+ assignments: WebAppRowGraphQL<TAssignment>[];
43
+ canAssign?: boolean;
44
+ loading?: boolean;
45
+ description?: string;
46
+ superAdminLabel?: string;
47
+ addEmployeeTitle?: string;
48
+ addEmployeeDescription?: string;
49
+ noEmployeesToAddText?: string;
50
+ noRolesText?: string;
51
+ assignmentDetailsColumn?: {
52
+ name: string;
53
+ minWidth?: string;
54
+ grow?: number;
55
+ };
56
+ renderAssignmentDetails?: (
57
+ details: WebappEmployeeAssignmentDetails<TAssignment>,
58
+ ) => ReactNode;
59
+ superAdminEmailSuffixes?: readonly string[];
60
+ onAssignRole: (
61
+ change: WebappEmployeeAssignmentChange<TAssignment>,
62
+ ) => void;
63
+ onAddEmployee: (employeeId: number, roleId: number) => void;
64
+ };
65
+
66
+ export function WebappEmployeeAccess<
67
+ TAssignment extends WebappEmployeeRole = WebappEmployeeRole,
68
+ >({
69
+ employees,
70
+ roles,
71
+ assignments,
72
+ canAssign = false,
73
+ loading = false,
74
+ description =
75
+ "Solo aparecen quienes ya tienen acceso: rol asignado o super admin. Quitar el rol los saca de la lista.",
76
+ superAdminLabel = "Super admin (todos los permisos)",
77
+ addEmployeeTitle = "Agregar empleado",
78
+ addEmployeeDescription =
79
+ "Busca entre quienes aún no están en la lista y asígnales un rol.",
80
+ noEmployeesToAddText = "No quedan empleados activos por agregar.",
81
+ noRolesText = "Crea al menos un rol antes de agregar empleados.",
82
+ assignmentDetailsColumn,
83
+ renderAssignmentDetails,
84
+ superAdminEmailSuffixes,
85
+ onAssignRole,
86
+ onAddEmployee,
87
+ }: Props<TAssignment>) {
88
+ const roleOptions: SelectFieldOption[] = useMemo(
89
+ () => [
90
+ { value: "", label: "Sin rol" },
91
+ ...roles.map((role) => ({
92
+ value: String(role.id),
93
+ label: role.data.name,
94
+ })),
95
+ ],
96
+ [roles],
97
+ );
98
+
99
+ const addRoleOptions: SelectFieldOption[] = useMemo(
100
+ () =>
101
+ roles.map((role) => ({
102
+ value: String(role.id),
103
+ label: role.data.name,
104
+ })),
105
+ [roles],
106
+ );
107
+
108
+ const assignmentByEmployee = useMemo(() => {
109
+ const map = new Map<number, WebAppRowGraphQL<TAssignment>>();
110
+ for (const row of assignments) {
111
+ map.set(Number(row.data.employee_id), row);
112
+ }
113
+ return map;
114
+ }, [assignments]);
115
+
116
+ const listedEmployees = useMemo(
117
+ () =>
118
+ employees
119
+ .filter(
120
+ (employee) =>
121
+ isSuperAdminEmail(employee.email, superAdminEmailSuffixes) ||
122
+ assignmentByEmployee.has(employee.id),
123
+ )
124
+ .sort((a, b) => a.name.localeCompare(b.name)),
125
+ [assignmentByEmployee, employees, superAdminEmailSuffixes],
126
+ );
127
+
128
+ const addEmployeeOptions: SelectFieldOption[] = useMemo(() => {
129
+ const listedIds = new Set(listedEmployees.map((employee) => employee.id));
130
+ return employees
131
+ .filter((employee) => !listedIds.has(employee.id))
132
+ .sort((a, b) => a.name.localeCompare(b.name))
133
+ .map((employee) => ({
134
+ value: employee.id,
135
+ label: employee.email
136
+ ? `${employee.name} (${employee.email})`
137
+ : employee.name,
138
+ }));
139
+ }, [employees, listedEmployees]);
140
+
141
+ const columns = [
142
+ {
143
+ name: "Empleado",
144
+ sortable: true,
145
+ grow: 1,
146
+ minWidth: "12rem",
147
+ selector: (employee: EmployeeGraphQL) => employee.name,
148
+ },
149
+ {
150
+ name: "Email",
151
+ sortable: true,
152
+ grow: 1,
153
+ minWidth: "14rem",
154
+ selector: (employee: EmployeeGraphQL) => employee.email || "—",
155
+ },
156
+ {
157
+ name: "Rol",
158
+ minWidth: "14rem",
159
+ cell: (employee: EmployeeGraphQL) => {
160
+ const assignment = assignmentByEmployee.get(employee.id);
161
+ const superAdmin = isSuperAdminEmail(
162
+ employee.email,
163
+ superAdminEmailSuffixes,
164
+ );
165
+ const currentRole = assignment
166
+ ? String(assignment.data.role_id)
167
+ : "";
168
+
169
+ return (
170
+ <div className="w-56 py-2">
171
+ {superAdmin ? (
172
+ <p className="text-sm text-gray-600">{superAdminLabel}</p>
173
+ ) : (
174
+ <SelectField
175
+ name={`role_${employee.id}`}
176
+ options={roleOptions}
177
+ defaultValue={
178
+ roleOptions.find(
179
+ (option) => option.value === currentRole,
180
+ ) ?? roleOptions[0]
181
+ }
182
+ disabled={!canAssign || loading}
183
+ onChange={(option) => {
184
+ const value = option?.value?.toString() ?? "";
185
+ onAssignRole({
186
+ employee,
187
+ assignment,
188
+ roleId: value ? Number(value) : null,
189
+ });
190
+ }}
191
+ />
192
+ )}
193
+ </div>
194
+ );
195
+ },
196
+ },
197
+ ...(assignmentDetailsColumn && renderAssignmentDetails
198
+ ? [
199
+ {
200
+ name: assignmentDetailsColumn.name,
201
+ minWidth: assignmentDetailsColumn.minWidth ?? "16rem",
202
+ grow: assignmentDetailsColumn.grow ?? 1,
203
+ cell: (employee: EmployeeGraphQL) => {
204
+ const assignment = assignmentByEmployee.get(employee.id);
205
+ return renderAssignmentDetails({
206
+ employee,
207
+ assignment,
208
+ disabled: !canAssign || !assignment || loading,
209
+ });
210
+ },
211
+ },
212
+ ]
213
+ : []),
214
+ ];
215
+
216
+ return (
217
+ <div className="mt-4 space-y-6">
218
+ <p className="text-sm text-gray-600">{description}</p>
219
+ <ZauruTable
220
+ columns={columns}
221
+ data={listedEmployees}
222
+ loading={loading}
223
+ offlineSearch={["name", "email"]}
224
+ />
225
+ {canAssign ? (
226
+ roles.length === 0 ? (
227
+ <p className="text-sm text-gray-600" role="status">
228
+ {noRolesText}
229
+ </p>
230
+ ) : addEmployeeOptions.length > 0 ? (
231
+ <div className="rounded-md border border-gray-200 bg-white p-4">
232
+ <ReactZodForm
233
+ schema={addEmployeeSchema}
234
+ onSubmit={(values) =>
235
+ onAddEmployee(
236
+ Number(values.employee_id),
237
+ Number(values.role_id),
238
+ )
239
+ }
240
+ >
241
+ <h2 className="text-base font-semibold text-gray-900">
242
+ {addEmployeeTitle}
243
+ </h2>
244
+ <p className="mb-4 mt-1 text-sm text-gray-600">
245
+ {addEmployeeDescription}
246
+ </p>
247
+ <DoubleFieldContainer alignCenter={false}>
248
+ <SelectField
249
+ name="employee_id"
250
+ title="Empleado"
251
+ hint="Escribe para filtrar por nombre o correo"
252
+ options={addEmployeeOptions}
253
+ required
254
+ />
255
+ <SelectField
256
+ name="role_id"
257
+ title="Rol"
258
+ options={addRoleOptions}
259
+ required
260
+ />
261
+ </DoubleFieldContainer>
262
+ <ButtonSectionContainer>
263
+ <Button loading={loading} title="Agregar" />
264
+ </ButtonSectionContainer>
265
+ </ReactZodForm>
266
+ </div>
267
+ ) : (
268
+ <p className="text-sm text-gray-600" role="status">
269
+ {noEmployeesToAddText}
270
+ </p>
271
+ )
272
+ ) : null}
273
+ </div>
274
+ );
275
+ }
@@ -0,0 +1,207 @@
1
+ import { PencilSvg, TrashSvg } from "@zauru-sdk/icons";
2
+ import type {
3
+ WebAppRowGraphQL,
4
+ WebappRole,
5
+ } from "@zauru-sdk/types";
6
+ import { useSyncExternalStore } from "react";
7
+ import { z } from "zod";
8
+ import { Button } from "../Buttons/Button.js";
9
+ import { ButtonSectionContainer } from "../Containers/ButtonSectionContainer.js";
10
+ import { ReactZodForm } from "../Form/ReactZodForm/index.js";
11
+ import { TextField } from "../Form/TextField/index.js";
12
+ import { createModal } from "../Modal/Modal.js";
13
+ import { ZauruTable } from "../Table/ZauruTable.js";
14
+
15
+ const roleSchema = z.object({
16
+ name: z.string().min(1, "El nombre es requerido"),
17
+ description: z.string().optional(),
18
+ });
19
+
20
+ const subscribeToBrowser = () => () => undefined;
21
+ const getBrowserSnapshot = () => true;
22
+ const getServerSnapshot = () => false;
23
+
24
+ export type WebappRoleValues = {
25
+ name: string;
26
+ description: string;
27
+ };
28
+
29
+ type Props = {
30
+ roles: WebAppRowGraphQL<WebappRole>[];
31
+ canCreate?: boolean;
32
+ canEdit?: boolean;
33
+ canDelete?: boolean;
34
+ loading?: boolean;
35
+ createTitle?: string;
36
+ createButtonLabel?: string;
37
+ editTitle?: string;
38
+ deleteTitle?: string;
39
+ deleteDescription?: string;
40
+ onCreate: (values: WebappRoleValues) => void;
41
+ onUpdate: (roleId: number, values: WebappRoleValues) => void;
42
+ onDelete: (roleId: number) => void;
43
+ };
44
+
45
+ export function WebappRoleManagement({
46
+ roles,
47
+ canCreate = false,
48
+ canEdit = false,
49
+ canDelete = false,
50
+ loading = false,
51
+ createTitle = "Crear rol",
52
+ createButtonLabel = "Crear rol",
53
+ editTitle = "Editar rol",
54
+ deleteTitle = "¿Eliminar este rol?",
55
+ deleteDescription = "No se puede eliminar si hay empleados asignados.",
56
+ onCreate,
57
+ onUpdate,
58
+ onDelete,
59
+ }: Props) {
60
+ const isClient = useSyncExternalStore(
61
+ subscribeToBrowser,
62
+ getBrowserSnapshot,
63
+ getServerSnapshot,
64
+ );
65
+
66
+ const editRole = async (role: WebAppRowGraphQL<WebappRole>) => {
67
+ let name = role.data.name;
68
+ let description = role.data.description ?? "";
69
+ const response = await createModal({
70
+ title: editTitle,
71
+ okButtonText: "Guardar",
72
+ description: (
73
+ <>
74
+ <TextField
75
+ title="Nombre"
76
+ name="name"
77
+ defaultValue={name}
78
+ onChange={(value) => {
79
+ name = value;
80
+ }}
81
+ />
82
+ <TextField
83
+ className="mt-4"
84
+ title="Descripción"
85
+ name="description"
86
+ defaultValue={description}
87
+ onChange={(value) => {
88
+ description = value;
89
+ }}
90
+ />
91
+ </>
92
+ ),
93
+ });
94
+
95
+ if (response === "OK") {
96
+ onUpdate(role.id, { name, description });
97
+ }
98
+ };
99
+
100
+ const confirmDelete = async (roleId: number) => {
101
+ const response = await createModal({
102
+ title: deleteTitle,
103
+ description: deleteDescription,
104
+ });
105
+ if (response === "OK") {
106
+ onDelete(roleId);
107
+ }
108
+ };
109
+
110
+ const columns = [
111
+ {
112
+ name: "Nombre",
113
+ sortable: true,
114
+ grow: 1,
115
+ selector: (role: WebAppRowGraphQL<WebappRole>) => role.data.name,
116
+ },
117
+ {
118
+ name: "Descripción",
119
+ grow: 2,
120
+ selector: (role: WebAppRowGraphQL<WebappRole>) =>
121
+ role.data.description || "—",
122
+ },
123
+ {
124
+ name: "Permisos",
125
+ width: "8%",
126
+ selector: (role: WebAppRowGraphQL<WebappRole>) =>
127
+ role.data.permission_keys?.length ?? 0,
128
+ },
129
+ {
130
+ name: "",
131
+ grow: 0,
132
+ minWidth: "90px",
133
+ maxWidth: "90px",
134
+ cell: (role: WebAppRowGraphQL<WebappRole>) => (
135
+ <div className="flex gap-2">
136
+ {canDelete ? (
137
+ <button
138
+ type="button"
139
+ title="Eliminar"
140
+ aria-label={`Eliminar rol ${role.data.name}`}
141
+ disabled={loading}
142
+ className="cursor-pointer rounded focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
143
+ onClick={() => void confirmDelete(role.id)}
144
+ >
145
+ <TrashSvg />
146
+ </button>
147
+ ) : null}
148
+ {canEdit ? (
149
+ <button
150
+ type="button"
151
+ title="Editar"
152
+ aria-label={`Editar rol ${role.data.name}`}
153
+ disabled={loading}
154
+ className="cursor-pointer rounded focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
155
+ onClick={() => void editRole(role)}
156
+ >
157
+ <PencilSvg />
158
+ </button>
159
+ ) : null}
160
+ </div>
161
+ ),
162
+ },
163
+ ];
164
+
165
+ if (!isClient) {
166
+ return null;
167
+ }
168
+
169
+ return (
170
+ <div className="mt-4 space-y-6">
171
+ <ZauruTable
172
+ columns={columns}
173
+ data={[...roles].sort((a, b) =>
174
+ a.data.name.localeCompare(b.data.name),
175
+ )}
176
+ loading={loading}
177
+ offlineSearch={["data.name", "data.description"]}
178
+ />
179
+ {canCreate ? (
180
+ <div className="rounded-md border border-gray-200 bg-white p-4">
181
+ <ReactZodForm
182
+ schema={roleSchema}
183
+ onSubmit={(values) =>
184
+ onCreate({
185
+ name: String(values.name ?? ""),
186
+ description: String(values.description ?? ""),
187
+ })
188
+ }
189
+ >
190
+ <h2 className="mb-4 text-base font-semibold text-gray-900">
191
+ {createTitle}
192
+ </h2>
193
+ <TextField name="name" title="Nombre del rol" required />
194
+ <TextField
195
+ className="mt-4"
196
+ name="description"
197
+ title="Descripción"
198
+ />
199
+ <ButtonSectionContainer>
200
+ <Button loading={loading} title={createButtonLabel} />
201
+ </ButtonSectionContainer>
202
+ </ReactZodForm>
203
+ </div>
204
+ ) : null}
205
+ </div>
206
+ );
207
+ }
@@ -0,0 +1,2 @@
1
+ export * from "./WebappEmployeeAccess.js";
2
+ export * from "./WebappRoleManagement.js";
@@ -4,6 +4,7 @@ import React, {
4
4
  useEffect,
5
5
  useState,
6
6
  useRef,
7
+ useId,
7
8
  KeyboardEvent,
8
9
  useCallback,
9
10
  } from "react";
@@ -89,6 +90,10 @@ export const SelectField = (props: Props) => {
89
90
  );
90
91
  const [isTabPressed, setIsTabPressed] = useState<boolean>(false);
91
92
  const [isSearching, setIsSearching] = useState<boolean>(false);
93
+ const generatedInputId = useId();
94
+ const inputId = id ?? (name ? `${name}-input` : generatedInputId);
95
+ const errorId = `${inputId}-error`;
96
+ const hintId = `${inputId}-hint`;
92
97
 
93
98
  // Use refs to store stable references to callbacks
94
99
  const onChangeRef = useRef(onChange);
@@ -409,12 +414,11 @@ export const SelectField = (props: Props) => {
409
414
  return (
410
415
  <>
411
416
  {title && (
412
- <label
413
- htmlFor={error ? `${name}-error` : `${name}-success`}
417
+ <span
414
418
  className={`block text-sm font-medium text-${color}-700 dark:text-${color}-500`}
415
419
  >
416
420
  {title}
417
- </label>
421
+ </span>
418
422
  )}
419
423
  <LoadingInputSkeleton />
420
424
  {helpText && (
@@ -432,7 +436,7 @@ export const SelectField = (props: Props) => {
432
436
  <div className={`col-span-6 sm:col-span-3 ${className}`} ref={selectRef}>
433
437
  {title && (
434
438
  <label
435
- htmlFor={error ? `${name}-error` : `${name}-success`}
439
+ htmlFor={inputId}
436
440
  className={`block text-sm font-medium ${
437
441
  color === "red"
438
442
  ? "text-red-700 dark:text-red-500"
@@ -447,7 +451,7 @@ export const SelectField = (props: Props) => {
447
451
  <div className="flex items-center">
448
452
  <input
449
453
  type="text"
450
- id={id}
454
+ id={inputId}
451
455
  value={inputValue}
452
456
  onFocus={() => setIsOpen(true)}
453
457
  onKeyDown={handleKeyDown}
@@ -459,14 +463,21 @@ export const SelectField = (props: Props) => {
459
463
  onChange={handleInputChange}
460
464
  onBlur={handleBlur}
461
465
  required={required}
466
+ aria-invalid={Boolean(error)}
467
+ aria-describedby={error ? errorId : hint ? hintId : undefined}
462
468
  />
463
469
  {isClearable && (value || valueMulti.length > 0) && (
464
470
  <button
465
471
  type="button"
466
472
  onClick={handleClear}
467
- className="absolute inset-y-0 right-0 pr-3 flex items-center"
473
+ aria-label={
474
+ isMulti
475
+ ? "Limpiar todas las opciones seleccionadas"
476
+ : "Limpiar la opción seleccionada"
477
+ }
478
+ className="absolute inset-y-0 right-0 flex cursor-pointer items-center pr-3 text-gray-600 hover:text-gray-900 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2"
468
479
  >
469
- ×
480
+ <span aria-hidden="true">×</span>
470
481
  </button>
471
482
  )}
472
483
  {helpText && (
@@ -542,32 +553,40 @@ export const SelectField = (props: Props) => {
542
553
  )}
543
554
  </div>
544
555
  {isMulti && valueMulti.length > 0 && (
545
- <div className="mt-2 flex flex-wrap gap-2">
556
+ <ul
557
+ className="mt-2 flex max-h-20 flex-wrap gap-1.5 overflow-auto overscroll-contain rounded-md border border-blue-100 bg-blue-50/40 p-1.5"
558
+ aria-label="Opciones seleccionadas"
559
+ >
546
560
  {valueMulti.map((option, index) => (
547
- <span
561
+ <li
548
562
  key={`${option.value}-${index}`}
549
- className="bg-blue-100 text-blue-800 text-xs font-semibold mr-2 px-2.5 py-0.5 rounded"
563
+ className="inline-flex max-w-full shrink-0 items-center rounded bg-blue-100 py-0.5 pl-2.5 pr-0.5 text-xs font-semibold text-blue-800"
550
564
  >
551
- {option.label}
565
+ <span className="whitespace-nowrap">{option.label}</span>
552
566
  <button
553
567
  type="button"
554
568
  onClick={() => handleRemoveMultiValue(option)}
555
- className="ml-1 text-blue-600 hover:text-blue-800"
569
+ aria-label={`Quitar ${option.label}`}
570
+ className="ml-1 inline-flex h-6 w-6 shrink-0 cursor-pointer items-center justify-center rounded text-blue-700 hover:bg-blue-200 hover:text-blue-900 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1"
556
571
  >
557
- ×
572
+ <span aria-hidden="true">×</span>
558
573
  </button>
559
- </span>
574
+ </li>
560
575
  ))}
561
- </div>
576
+ </ul>
562
577
  )}
563
578
  {error && (
564
- <p className={`mt-2 text-sm text-${color}-600 dark:text-${color}-500`}>
579
+ <p
580
+ id={errorId}
581
+ className={`mt-2 text-sm text-${color}-600 dark:text-${color}-500`}
582
+ >
565
583
  <span className="font-medium">Oops!</span>{" "}
566
584
  {error?.message?.toString() || "Error desconocido"}
567
585
  </p>
568
586
  )}
569
587
  {!error && hint && (
570
588
  <p
589
+ id={hintId}
571
590
  className={`mt-2 italic text-sm text-${color}-500 dark:text-${color}-400`}
572
591
  >
573
592
  {hint}
@@ -174,6 +174,8 @@ function GroupRows({
174
174
  >
175
175
  <input
176
176
  type="checkbox"
177
+ id={`permission-${role.id}-${action.key}`}
178
+ name={`permission-${role.id}-${action.key}`}
177
179
  className={checkboxClass}
178
180
  checked={granted}
179
181
  disabled={disabled || pending}
package/src/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from "./Alerts/index.js";
2
+ export * from "./AccessControl/index.js";
2
3
  export * from "./BlockUI/index.js";
3
4
  export * from "./Buttons/index.js";
4
5
  export * from "./Card/index.js";