@zauru-sdk/components 6.18.0 → 6.19.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.
Files changed (47) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/build/assets/Alerts.stories-CSBQ8R_t.js +1 -0
  3. package/build/assets/Button-HdDVNit0.js +1 -0
  4. package/build/assets/Button.stories-4PbdRo1_.js +1 -0
  5. package/build/assets/Card.stories-QwjOcKTY.js +1 -0
  6. package/build/assets/Container.stories-pWk2cF23.js +1 -0
  7. package/build/assets/Form.stories-lVdn9hAH.js +1 -0
  8. package/build/assets/Icons-DjqYUpg9.js +10 -0
  9. package/build/assets/Labels.stories-FX8D89vw.js +1 -0
  10. package/build/assets/Matrix.stories-Cxd7sZW4.js +1 -0
  11. package/build/assets/Modal.stories-CgG40zUJ.js +1 -0
  12. package/build/assets/NavBar.stories-BPC2tPzi.js +1 -0
  13. package/build/assets/SidePanel.stories-Atykcy9h.js +1 -0
  14. package/build/assets/Tab.stories-qL5aJzJZ.js +365 -0
  15. package/build/assets/Table.stories-JhlKDwAx.js +416 -0
  16. package/build/assets/Titles.stories-BIaWnH29.js +1 -0
  17. package/build/assets/Tooltip.stories-iBejLWUg.js +1 -0
  18. package/build/assets/activestorage.esm-ByWQ3T27.js +1 -0
  19. package/build/assets/axe-VDwKxXpa.js +30 -0
  20. package/build/assets/components-TO4aWqdC.js +46 -0
  21. package/build/assets/favicon-gjFwcFbF.svg +22 -0
  22. package/build/assets/index-CraKZ-Fr.css +1 -0
  23. package/build/assets/index-DxYjnckq.js +152 -0
  24. package/build/assets/index-TgOqmrBM.js +1 -0
  25. package/build/assets/manifest-B-VxemCC.webmanifest +7 -0
  26. package/build/assets/proxy-DgkUxN7K.js +9 -0
  27. package/build/assets/touch-icon-ByyUZD7t.png +0 -0
  28. package/build/index.html +28 -0
  29. package/build/meta.json +416 -0
  30. package/dist/Matrix/CheckMatrixCards.d.ts +27 -0
  31. package/dist/Matrix/ResponsiveCheckMatrix.d.ts +7 -0
  32. package/dist/Matrix/index.d.ts +2 -0
  33. package/dist/SidePanel/index.d.ts +4 -2
  34. package/dist/esm/Matrix/CheckMatrixCards.js +25 -0
  35. package/dist/esm/Matrix/ResponsiveCheckMatrix.js +11 -0
  36. package/dist/esm/Matrix/index.js +2 -0
  37. package/dist/esm/SidePanel/index.js +47 -10
  38. package/dist/esm/index.js +1 -0
  39. package/dist/index.d.ts +1 -0
  40. package/package.json +9 -9
  41. package/src/Matrix/CheckMatrixCards.tsx +137 -0
  42. package/src/Matrix/ResponsiveCheckMatrix.tsx +32 -0
  43. package/src/Matrix/index.ts +2 -0
  44. package/src/SidePanel/index.tsx +96 -12
  45. package/src/index.ts +1 -0
  46. package/stories/Matrix.stories.tsx +75 -0
  47. package/stories/SidePanel.stories.tsx +21 -0
@@ -0,0 +1,137 @@
1
+ import React, { ReactNode, useEffect, useState } from "react";
2
+
3
+ export type CheckMatrixColumn = {
4
+ key: string;
5
+ label: ReactNode;
6
+ };
7
+
8
+ export type CheckMatrixRow = {
9
+ key: string;
10
+ label: ReactNode;
11
+ description?: ReactNode;
12
+ };
13
+
14
+ export interface CheckMatrixCardsProps {
15
+ rows: CheckMatrixRow[];
16
+ columns: CheckMatrixColumn[];
17
+ isChecked: (rowKey: string, columnKey: string) => boolean;
18
+ isPending?: (rowKey: string, columnKey: string) => boolean;
19
+ onToggle: (rowKey: string, columnKey: string, checked: boolean) => void;
20
+ disabled?: boolean;
21
+ renderRowAction?: (row: CheckMatrixRow) => ReactNode;
22
+ checkedLabel?: string;
23
+ uncheckedLabel?: string;
24
+ pendingLabel?: string;
25
+ emptyText?: string;
26
+ pageSize?: number;
27
+ showMoreLabel?: string;
28
+ className?: string;
29
+ }
30
+
31
+ export const CheckMatrixCards = ({
32
+ rows,
33
+ columns,
34
+ isChecked,
35
+ isPending = () => false,
36
+ onToggle,
37
+ disabled = false,
38
+ renderRowAction,
39
+ checkedLabel = "Marcado",
40
+ uncheckedLabel = "Sin marcar",
41
+ pendingLabel = "Guardando…",
42
+ emptyText = "No hay filas para mostrar.",
43
+ pageSize = 20,
44
+ showMoreLabel = "Mostrar más",
45
+ className = "",
46
+ }: CheckMatrixCardsProps) => {
47
+ const safePageSize = Math.max(1, pageSize);
48
+ const [visibleCount, setVisibleCount] = useState(safePageSize);
49
+
50
+ useEffect(() => {
51
+ setVisibleCount(safePageSize);
52
+ }, [rows, safePageSize]);
53
+
54
+ if (rows.length === 0) {
55
+ return <p className={`p-4 text-sm text-gray-600 ${className}`}>{emptyText}</p>;
56
+ }
57
+
58
+ const visibleRows = rows.slice(0, visibleCount);
59
+
60
+ return (
61
+ <div className={`space-y-3 ${className}`}>
62
+ {visibleRows.map((row) => (
63
+ <fieldset
64
+ key={row.key}
65
+ className="min-w-0 rounded-lg border border-gray-200 bg-white p-3 shadow-sm"
66
+ >
67
+ <div className="mb-3 flex min-w-0 items-start justify-between gap-3">
68
+ <legend className="min-w-0 flex-1 px-0 text-sm font-semibold text-gray-900">
69
+ <span className="block break-words">{row.label}</span>
70
+ {row.description ? (
71
+ <span className="mt-0.5 block break-words text-xs font-normal text-gray-600">
72
+ {row.description}
73
+ </span>
74
+ ) : null}
75
+ </legend>
76
+ {renderRowAction ? (
77
+ <div className="shrink-0">{renderRowAction(row)}</div>
78
+ ) : null}
79
+ </div>
80
+ <div className="grid gap-2 sm:grid-cols-2">
81
+ {columns.map((column) => {
82
+ const checked = isChecked(row.key, column.key);
83
+ const pending = isPending(row.key, column.key);
84
+ const inputId = `check-matrix-${encodeURIComponent(row.key)}-${encodeURIComponent(column.key)}`;
85
+ return (
86
+ <label
87
+ key={column.key}
88
+ htmlFor={inputId}
89
+ className={`flex min-h-11 cursor-pointer items-center gap-3 rounded-md border px-3 py-2 text-sm focus-within:ring-2 focus-within:ring-indigo-500 focus-within:ring-offset-1 ${
90
+ checked
91
+ ? "border-indigo-200 bg-indigo-50 text-indigo-950"
92
+ : "border-gray-200 bg-gray-50 text-gray-800"
93
+ } ${pending || disabled ? "cursor-not-allowed opacity-70" : "hover:border-indigo-300"}`}
94
+ >
95
+ <input
96
+ id={inputId}
97
+ type="checkbox"
98
+ checked={checked}
99
+ disabled={disabled || pending}
100
+ onChange={(event) =>
101
+ onToggle(row.key, column.key, event.target.checked)
102
+ }
103
+ className="h-5 w-5 shrink-0 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
104
+ />
105
+ <span className="min-w-0 flex-1 break-words font-medium">
106
+ {column.label}
107
+ </span>
108
+ <span
109
+ className={`shrink-0 text-xs font-medium ${
110
+ checked ? "text-indigo-700" : "text-gray-600"
111
+ }`}
112
+ aria-live={pending ? "polite" : undefined}
113
+ >
114
+ {pending
115
+ ? pendingLabel
116
+ : checked
117
+ ? checkedLabel
118
+ : uncheckedLabel}
119
+ </span>
120
+ </label>
121
+ );
122
+ })}
123
+ </div>
124
+ </fieldset>
125
+ ))}
126
+ {visibleCount < rows.length ? (
127
+ <button
128
+ type="button"
129
+ onClick={() => setVisibleCount((count) => count + safePageSize)}
130
+ className="min-h-11 w-full cursor-pointer rounded-md border border-indigo-300 bg-white px-4 py-2 text-sm font-semibold text-indigo-700 hover:bg-indigo-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2"
131
+ >
132
+ {showMoreLabel} ({rows.length - visibleCount})
133
+ </button>
134
+ ) : null}
135
+ </div>
136
+ );
137
+ };
@@ -0,0 +1,32 @@
1
+ import React, { ReactNode } from "react";
2
+ import {
3
+ CheckMatrixCards,
4
+ CheckMatrixCardsProps,
5
+ } from "./CheckMatrixCards.js";
6
+
7
+ export interface ResponsiveCheckMatrixProps extends CheckMatrixCardsProps {
8
+ desktop: ReactNode;
9
+ desktopBreakpoint?: "sm" | "md" | "lg";
10
+ }
11
+
12
+ const visibilityClasses = {
13
+ sm: { mobile: "sm:hidden", desktop: "hidden sm:block" },
14
+ md: { mobile: "md:hidden", desktop: "hidden md:block" },
15
+ lg: { mobile: "lg:hidden", desktop: "hidden lg:block" },
16
+ } as const;
17
+
18
+ export const ResponsiveCheckMatrix = ({
19
+ desktop,
20
+ desktopBreakpoint = "md",
21
+ ...cardProps
22
+ }: ResponsiveCheckMatrixProps) => {
23
+ const visibility = visibilityClasses[desktopBreakpoint];
24
+ return (
25
+ <>
26
+ <div className={visibility.mobile}>
27
+ <CheckMatrixCards {...cardProps} />
28
+ </div>
29
+ <div className={visibility.desktop}>{desktop}</div>
30
+ </>
31
+ );
32
+ };
@@ -0,0 +1,2 @@
1
+ export * from "./CheckMatrixCards.js";
2
+ export * from "./ResponsiveCheckMatrix.js";
@@ -1,5 +1,11 @@
1
- import React, { useState, ReactNode, useEffect, useRef } from "react";
2
- import { motion, AnimatePresence } from "framer-motion";
1
+ import React, {
2
+ useState,
3
+ ReactNode,
4
+ useEffect,
5
+ useId,
6
+ useRef,
7
+ } from "react";
8
+ import { motion, AnimatePresence, useReducedMotion } from "framer-motion";
3
9
 
4
10
  /**
5
11
  * SidePanel Component
@@ -14,11 +20,14 @@ import { motion, AnimatePresence } from "framer-motion";
14
20
  * @returns A toggleable side panel component.
15
21
  */
16
22
 
17
- interface SidePanelProps {
23
+ export interface SidePanelProps {
18
24
  children: ReactNode;
19
25
  closeOnClickOutside?: boolean;
20
26
  widthPercentage?: number;
21
27
  buttonIcon?: "chevron" | "filter" | ReactNode;
28
+ ariaLabel?: string;
29
+ openButtonLabel?: string;
30
+ closeButtonLabel?: string;
22
31
  }
23
32
 
24
33
  // SVG icon for the left-pointing chevron
@@ -28,6 +37,8 @@ const ChevronLeftIcon = () => (
28
37
  viewBox="0 0 24 24"
29
38
  fill="currentColor"
30
39
  className="h-6 w-6"
40
+ aria-hidden="true"
41
+ focusable="false"
31
42
  >
32
43
  <path
33
44
  fillRule="evenodd"
@@ -44,6 +55,8 @@ const ChevronRightIcon = () => (
44
55
  viewBox="0 0 24 24"
45
56
  fill="currentColor"
46
57
  className="h-6 w-6"
58
+ aria-hidden="true"
59
+ focusable="false"
47
60
  >
48
61
  <path
49
62
  fillRule="evenodd"
@@ -60,6 +73,8 @@ const FilterIcon = () => (
60
73
  viewBox="0 0 24 24"
61
74
  fill="currentColor"
62
75
  className="h-6 w-6"
76
+ aria-hidden="true"
77
+ focusable="false"
63
78
  >
64
79
  <path
65
80
  fillRule="evenodd"
@@ -74,17 +89,26 @@ export const SidePanel: React.FC<SidePanelProps> = ({
74
89
  closeOnClickOutside = true,
75
90
  widthPercentage = 25,
76
91
  buttonIcon = "chevron",
92
+ ariaLabel = "Panel lateral",
93
+ openButtonLabel = "Abrir panel",
94
+ closeButtonLabel = "Cerrar panel",
77
95
  }) => {
78
96
  const [isOpen, setIsOpen] = useState(false);
79
97
  const panelRef = useRef<HTMLDivElement>(null);
98
+ const closeButtonRef = useRef<HTMLButtonElement>(null);
99
+ const triggerButtonRef = useRef<HTMLButtonElement>(null);
100
+ const wasOpenRef = useRef(false);
101
+ const panelId = useId();
102
+ const shouldReduceMotion = useReducedMotion();
80
103
 
81
104
  const togglePanel = () => {
82
- setIsOpen(!isOpen);
105
+ setIsOpen((open) => !open);
83
106
  };
84
107
 
85
108
  useEffect(() => {
86
109
  const handleClickOutside = (event: MouseEvent) => {
87
110
  if (
111
+ isOpen &&
88
112
  closeOnClickOutside &&
89
113
  panelRef.current &&
90
114
  !panelRef.current.contains(event.target as Node)
@@ -97,7 +121,34 @@ export const SidePanel: React.FC<SidePanelProps> = ({
97
121
  return () => {
98
122
  document.removeEventListener("mousedown", handleClickOutside);
99
123
  };
100
- }, [closeOnClickOutside]);
124
+ }, [closeOnClickOutside, isOpen]);
125
+
126
+ useEffect(() => {
127
+ if (!isOpen) return;
128
+ const handleEscape = (event: KeyboardEvent) => {
129
+ if (event.key === "Escape") setIsOpen(false);
130
+ };
131
+ document.addEventListener("keydown", handleEscape);
132
+ return () => document.removeEventListener("keydown", handleEscape);
133
+ }, [isOpen]);
134
+
135
+ useEffect(() => {
136
+ if (isOpen) {
137
+ wasOpenRef.current = true;
138
+ closeButtonRef.current?.focus();
139
+ const previousOverflow = document.body.style.overflow;
140
+ if (window.matchMedia("(max-width: 767px)").matches) {
141
+ document.body.style.overflow = "hidden";
142
+ }
143
+ return () => {
144
+ document.body.style.overflow = previousOverflow;
145
+ };
146
+ }
147
+ if (wasOpenRef.current) {
148
+ wasOpenRef.current = false;
149
+ triggerButtonRef.current?.focus();
150
+ }
151
+ }, [isOpen]);
101
152
 
102
153
  const renderIcon = () => {
103
154
  if (typeof buttonIcon === "string") {
@@ -117,30 +168,63 @@ export const SidePanel: React.FC<SidePanelProps> = ({
117
168
  return (
118
169
  <>
119
170
  <motion.div
171
+ id={panelId}
120
172
  ref={panelRef}
173
+ role="dialog"
174
+ aria-modal={isOpen ? "true" : undefined}
175
+ aria-label={ariaLabel}
176
+ aria-hidden={!isOpen}
121
177
  initial={{ x: "100%" }}
122
178
  animate={{ x: isOpen ? 0 : "100%" }}
123
- transition={{ type: "spring", stiffness: 300, damping: 30 }}
124
- className={`fixed top-0 right-0 h-full bg-white shadow-lg z-[9999] overflow-y-auto`}
125
- style={{ width: `${widthPercentage}%` }}
179
+ transition={
180
+ shouldReduceMotion
181
+ ? { duration: 0 }
182
+ : { type: "spring", stiffness: 300, damping: 30 }
183
+ }
184
+ className="fixed right-0 top-0 z-[9999] h-full w-[calc(100%-1rem)] overflow-y-auto bg-white shadow-lg sm:w-[min(28rem,calc(100%-2rem))] md:w-[var(--side-panel-width)]"
185
+ style={
186
+ {
187
+ "--side-panel-width": `${widthPercentage}%`,
188
+ } as React.CSSProperties
189
+ }
126
190
  >
127
191
  <button
192
+ ref={closeButtonRef}
193
+ type="button"
128
194
  onClick={togglePanel}
129
- className="absolute top-4 left-4 p-2 rounded-full bg-gray-200 hover:bg-gray-300 transition-colors"
195
+ aria-label={closeButtonLabel}
196
+ className="absolute left-4 top-4 min-h-11 min-w-11 cursor-pointer rounded-full bg-gray-200 p-2 transition-colors hover:bg-gray-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 md:min-h-0 md:min-w-0"
130
197
  >
131
198
  <ChevronRightIcon />
132
199
  </button>
133
- <div className="p-6 mt-16">{children}</div>
200
+ <div className="mt-16 p-4 sm:p-6">{children}</div>
134
201
  </motion.div>
135
202
  <AnimatePresence>
203
+ {isOpen && closeOnClickOutside ? (
204
+ <motion.button
205
+ type="button"
206
+ aria-label={closeButtonLabel}
207
+ initial={{ opacity: 0 }}
208
+ animate={{ opacity: 1 }}
209
+ exit={{ opacity: 0 }}
210
+ transition={{ duration: shouldReduceMotion ? 0 : 0.15 }}
211
+ onClick={() => setIsOpen(false)}
212
+ className="fixed inset-0 z-[9998] cursor-default bg-black/30 md:hidden"
213
+ />
214
+ ) : null}
136
215
  {!isOpen && (
137
216
  <motion.button
217
+ ref={triggerButtonRef}
218
+ type="button"
219
+ aria-label={openButtonLabel}
220
+ aria-expanded={isOpen}
221
+ aria-controls={panelId}
138
222
  initial={{ opacity: 0, x: 20 }}
139
223
  animate={{ opacity: 1, x: 0 }}
140
224
  exit={{ opacity: 0, x: 20 }}
141
- transition={{ duration: 0.2 }}
225
+ transition={{ duration: shouldReduceMotion ? 0 : 0.2 }}
142
226
  onClick={togglePanel}
143
- className={`fixed top-1/2 right-0 transform -translate-y-1/2 bg-indigo-600 text-white p-3 rounded-l-lg shadow-md hover:bg-indigo-700 transition-colors z-[10000]`}
227
+ className="fixed right-0 top-1/2 z-[10000] min-h-11 min-w-11 -translate-y-1/2 cursor-pointer rounded-l-lg bg-indigo-600 p-3 text-white shadow-md transition-colors hover:bg-indigo-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2"
144
228
  >
145
229
  {renderIcon()}
146
230
  </motion.button>
package/src/index.ts CHANGED
@@ -3,6 +3,7 @@ export * from "./AccessControl/index.js";
3
3
  export * from "./BlockUI/index.js";
4
4
  export * from "./Buttons/index.js";
5
5
  export * from "./Card/index.js";
6
+ export * from "./Matrix/index.js";
6
7
  export * from "./ConnectionState/index.js";
7
8
  export * from "./Chat/index.js";
8
9
  export * from "./Containers/index.js";
@@ -0,0 +1,75 @@
1
+ import { useState } from "react";
2
+ import { ResponsiveCheckMatrix } from "../src/Matrix/ResponsiveCheckMatrix.js";
3
+
4
+ const rows = [
5
+ { key: "129", label: "129 · Cooperativa El Progreso", description: "San Juan" },
6
+ { key: "302", label: "302 · Productor con un nombre largo para validar el ajuste de texto" },
7
+ ];
8
+ const columns = [
9
+ { key: "brocoli", label: "Brócoli certificado" },
10
+ { key: "radicchio", label: "Radicchio" },
11
+ { key: "elotin", label: "Elotín" },
12
+ ];
13
+
14
+ export const Responsive = () => {
15
+ const [blocked, setBlocked] = useState(new Set(["129:radicchio"]));
16
+ const isChecked = (rowKey: string, columnKey: string) =>
17
+ !blocked.has(`${rowKey}:${columnKey}`);
18
+ const onToggle = (rowKey: string, columnKey: string, checked: boolean) => {
19
+ setBlocked((current) => {
20
+ const next = new Set(current);
21
+ const key = `${rowKey}:${columnKey}`;
22
+ if (checked) next.delete(key);
23
+ else next.add(key);
24
+ return next;
25
+ });
26
+ };
27
+
28
+ const desktop = (
29
+ <div className="overflow-x-auto rounded-md border border-gray-200">
30
+ <table className="min-w-full text-sm">
31
+ <thead className="bg-gray-50">
32
+ <tr>
33
+ <th className="px-3 py-2 text-left">Proveedor</th>
34
+ {columns.map((column) => (
35
+ <th key={column.key} className="px-3 py-2 text-center">
36
+ {column.label}
37
+ </th>
38
+ ))}
39
+ </tr>
40
+ </thead>
41
+ <tbody>
42
+ {rows.map((row) => (
43
+ <tr key={row.key} className="border-t border-gray-200">
44
+ <th className="px-3 py-2 text-left">{row.label}</th>
45
+ {columns.map((column) => (
46
+ <td key={column.key} className="px-3 py-2 text-center">
47
+ <input
48
+ type="checkbox"
49
+ aria-label={`${String(row.label)}, ${String(column.label)}`}
50
+ checked={isChecked(row.key, column.key)}
51
+ onChange={(event) =>
52
+ onToggle(row.key, column.key, event.target.checked)
53
+ }
54
+ />
55
+ </td>
56
+ ))}
57
+ </tr>
58
+ ))}
59
+ </tbody>
60
+ </table>
61
+ </div>
62
+ );
63
+
64
+ return (
65
+ <ResponsiveCheckMatrix
66
+ rows={rows}
67
+ columns={columns}
68
+ desktop={desktop}
69
+ isChecked={isChecked}
70
+ onToggle={onToggle}
71
+ checkedLabel="Permitida"
72
+ uncheckedLabel="Bloqueada"
73
+ />
74
+ );
75
+ };
@@ -0,0 +1,21 @@
1
+ import { SidePanel } from "../src/SidePanel/index.js";
2
+
3
+ export const Filters = () => (
4
+ <div className="min-h-[36rem] bg-gray-50 p-6">
5
+ <p className="text-sm text-gray-700">
6
+ Use el botón fijo para abrir los filtros.
7
+ </p>
8
+ <SidePanel buttonIcon="filter" ariaLabel="Filtros de búsqueda">
9
+ <h2 className="text-lg font-semibold text-gray-900">
10
+ Filtros de búsqueda
11
+ </h2>
12
+ <label className="mt-4 block text-sm font-medium text-gray-700">
13
+ Proveedor
14
+ <input
15
+ className="mt-1 block w-full rounded-md border-gray-300"
16
+ placeholder="Nombre o código"
17
+ />
18
+ </label>
19
+ </SidePanel>
20
+ </div>
21
+ );