@sesionario/blocks 1.20.0 → 1.21.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/README.md CHANGED
@@ -6,6 +6,81 @@ This package is the Sesionario-specific successor to `@algenium/blocks`. It ship
6
6
 
7
7
  Shared feedback patterns include `ConfirmDestructiveDialog` (confirm before delete/mute and similar irreversible actions).
8
8
 
9
+ ### Read-first editable sections (`EditableSection`, `DataList`, `FieldMessage`)
10
+
11
+ Settings and profile data should be readable by default, then turn into a real `<form>` when the person chooses Edit. The primitives are chrome-free — no Card, border, or title — so you place actions in your own layout (`CardAction`, page header, dialog, or a bare section).
12
+
13
+ ```tsx
14
+ import {
15
+ EditableSection,
16
+ DataList,
17
+ DataListItem,
18
+ FieldMessage,
19
+ } from "@sesionario/blocks";
20
+
21
+ <EditableSection
22
+ labelledBy="fiscal-title"
23
+ isDirty={dirty}
24
+ onDiscard={() => reset()}
25
+ onSave={async () => {
26
+ /* throw on validation/API failure so edit mode stays open */
27
+ await save();
28
+ }}
29
+ labels={{
30
+ edit: t("edit"),
31
+ save: t("save"),
32
+ saving: t("saving"),
33
+ cancel: t("cancel"),
34
+ saved: t("saved"),
35
+ }}
36
+ readView={() => (
37
+ <DataList>
38
+ <DataListItem label="RFC">{rfc}</DataListItem>
39
+ </DataList>
40
+ )}
41
+ editView={() => (
42
+ <div className="flex flex-col gap-1.5">
43
+ <label htmlFor="rfc">RFC</label>
44
+ <input id="rfc" value={rfc} onChange={...} aria-invalid={!!error} aria-describedby="rfc-err" />
45
+ <FieldMessage id="rfc-err">{error}</FieldMessage>
46
+ </div>
47
+ )}
48
+ >
49
+ {({ actions, content, status }) => (
50
+ <Card>
51
+ <CardHeader>
52
+ <CardTitle id="fiscal-title">Datos fiscales</CardTitle>
53
+ <CardAction>{actions}</CardAction>
54
+ </CardHeader>
55
+ <CardContent>
56
+ {content}
57
+ {status}
58
+ </CardContent>
59
+ </Card>
60
+ )}
61
+ </EditableSection>
62
+ ```
63
+
64
+ Headless alternative: `useEditableSection` for card-less surfaces (dialogs, table rows, page headers).
65
+
66
+ Optional progressive-enhancement CSS for a smoother height change (Chromium 129+; no-op elsewhere). Honor reduced motion:
67
+
68
+ ```css
69
+ @supports (interpolate-size: allow-keywords) {
70
+ .sesionario-editable-section-content {
71
+ interpolate-size: allow-keywords;
72
+ transition: min-height 200ms ease;
73
+ }
74
+ }
75
+ @media (prefers-reduced-motion: reduce) {
76
+ .sesionario-editable-section-content {
77
+ transition: none;
78
+ }
79
+ }
80
+ ```
81
+
82
+ Pass `stableHeight` for small (1–2 field) sections if you want later toggles to keep a cached `min-height`.
83
+
9
84
  ## Install
10
85
 
11
86
  ```bash
package/dist/index.cjs CHANGED
@@ -456,6 +456,348 @@ function ConfirmDestructiveDialog({
456
456
  ] })
457
457
  ] }) });
458
458
  }
459
+
460
+ // src/components/editable/types.ts
461
+ var DEFAULT_EDITABLE_SECTION_LABELS = {
462
+ edit: "Editar",
463
+ save: "Guardar",
464
+ saving: "Guardando\u2026",
465
+ cancel: "Cancelar",
466
+ saved: "Guardado",
467
+ discardTitle: "\xBFDescartar cambios?",
468
+ discardDescription: "Si sales ahora, se perder\xE1n los cambios que no hayas guardado.",
469
+ discardConfirm: "Descartar",
470
+ discardCancel: "Seguir editando"
471
+ };
472
+
473
+ // src/components/editable/useEditableSection.ts
474
+ function focusFirstField(container) {
475
+ if (!container) return;
476
+ const target = container.querySelector(
477
+ "[data-autofocus], input:not([type='hidden']):not([disabled]), select:not([disabled]), textarea:not([disabled]), [contenteditable='true']"
478
+ );
479
+ target?.focus();
480
+ }
481
+ function useEditableSection({
482
+ onSave,
483
+ onDiscard,
484
+ isDirty = false,
485
+ labelledBy,
486
+ title,
487
+ labels,
488
+ stableHeight = false
489
+ }) {
490
+ const resolvedLabels = {
491
+ ...DEFAULT_EDITABLE_SECTION_LABELS,
492
+ ...labels
493
+ };
494
+ const [mode, setMode] = React7.useState("reading");
495
+ const [status, setStatus] = React7.useState(null);
496
+ const [confirmOpen, setConfirmOpen] = React7.useState(false);
497
+ const [minHeightPx, setMinHeightPx] = React7.useState(void 0);
498
+ const contentRef = React7.useRef(null);
499
+ const editButtonRef = React7.useRef(null);
500
+ const measuredHeightRef = React7.useRef(0);
501
+ const shouldFocusEditRef = React7.useRef(false);
502
+ const shouldFocusFirstFieldRef = React7.useRef(false);
503
+ const editButtonId = React7.useId();
504
+ const contentFormId = React7.useId();
505
+ const isEditing = mode === "editing" || mode === "saving";
506
+ const isSaving = mode === "saving";
507
+ React7.useEffect(() => {
508
+ if (mode === "reading" && shouldFocusEditRef.current) {
509
+ shouldFocusEditRef.current = false;
510
+ editButtonRef.current?.focus();
511
+ return;
512
+ }
513
+ if (mode === "editing" && shouldFocusFirstFieldRef.current) {
514
+ shouldFocusFirstFieldRef.current = false;
515
+ const invalid = contentRef.current?.querySelector(
516
+ "[aria-invalid='true']"
517
+ );
518
+ if (invalid) {
519
+ invalid.focus();
520
+ return;
521
+ }
522
+ focusFirstField(contentRef.current);
523
+ }
524
+ }, [mode]);
525
+ const exitToReading = React7.useCallback(() => {
526
+ setConfirmOpen(false);
527
+ shouldFocusEditRef.current = true;
528
+ setMode("reading");
529
+ onDiscard?.();
530
+ }, [onDiscard]);
531
+ const startEdit = React7.useCallback(() => {
532
+ setStatus(null);
533
+ shouldFocusFirstFieldRef.current = true;
534
+ setMode("editing");
535
+ }, []);
536
+ const requestCancel = React7.useCallback(() => {
537
+ if (isSaving) return;
538
+ if (isDirty) {
539
+ setConfirmOpen(true);
540
+ return;
541
+ }
542
+ exitToReading();
543
+ }, [exitToReading, isDirty, isSaving]);
544
+ const handleSubmit = React7.useCallback(
545
+ (event) => {
546
+ event.preventDefault();
547
+ if (isSaving) return;
548
+ void (async () => {
549
+ setMode("saving");
550
+ setStatus(null);
551
+ try {
552
+ await onSave();
553
+ setStatus(resolvedLabels.saved);
554
+ shouldFocusEditRef.current = true;
555
+ setMode("reading");
556
+ } catch {
557
+ shouldFocusFirstFieldRef.current = true;
558
+ setMode("editing");
559
+ }
560
+ })();
561
+ },
562
+ [isSaving, onSave, resolvedLabels.saved]
563
+ );
564
+ const handleFormKeyDown = React7.useCallback(
565
+ (event) => {
566
+ if (event.key !== "Escape") return;
567
+ event.preventDefault();
568
+ requestCancel();
569
+ },
570
+ [requestCancel]
571
+ );
572
+ React7.useEffect(() => {
573
+ if (!stableHeight || !isEditing) return;
574
+ const el = contentRef.current;
575
+ if (!el) return;
576
+ const measure = () => {
577
+ const height = el.getBoundingClientRect().height;
578
+ if (height > measuredHeightRef.current) {
579
+ measuredHeightRef.current = height;
580
+ setMinHeightPx(height);
581
+ }
582
+ };
583
+ measure();
584
+ const observer = new ResizeObserver(measure);
585
+ observer.observe(el);
586
+ return () => observer.disconnect();
587
+ }, [isEditing, stableHeight]);
588
+ const editButtonNameProps = labelledBy != null ? { "aria-labelledby": `${labelledBy} ${editButtonId}` } : title != null && title.length > 0 ? { "aria-label": `${resolvedLabels.edit} ${title}` } : { "aria-label": resolvedLabels.edit };
589
+ return {
590
+ mode,
591
+ isEditing,
592
+ isSaving,
593
+ startEdit,
594
+ requestCancel,
595
+ formProps: {
596
+ onSubmit: handleSubmit,
597
+ onKeyDown: handleFormKeyDown,
598
+ noValidate: true
599
+ },
600
+ contentRef,
601
+ editButtonRef,
602
+ contentFormId,
603
+ editButtonProps: {
604
+ type: "button",
605
+ id: editButtonId,
606
+ onClick: startEdit,
607
+ ref: editButtonRef,
608
+ ...editButtonNameProps
609
+ },
610
+ saveButtonProps: {
611
+ type: "submit",
612
+ disabled: isSaving
613
+ },
614
+ cancelButtonProps: {
615
+ type: "button",
616
+ onClick: requestCancel,
617
+ disabled: isSaving
618
+ },
619
+ status,
620
+ statusProps: {
621
+ role: "status",
622
+ "aria-live": "polite",
623
+ className: "sr-only"
624
+ },
625
+ confirmDiscard: {
626
+ open: confirmOpen,
627
+ onOpenChange: setConfirmOpen,
628
+ title: resolvedLabels.discardTitle,
629
+ description: resolvedLabels.discardDescription,
630
+ confirmLabel: resolvedLabels.discardConfirm,
631
+ cancelLabel: resolvedLabels.discardCancel,
632
+ onConfirm: exitToReading
633
+ },
634
+ resolvedLabels,
635
+ contentStyle: stableHeight && minHeightPx != null ? { minHeight: minHeightPx } : void 0,
636
+ actionsClassName: "flex min-h-9 min-w-[11rem] flex-wrap items-center justify-end gap-2"
637
+ };
638
+ }
639
+ function EditableSection({
640
+ readView,
641
+ editView,
642
+ children,
643
+ className,
644
+ ...options
645
+ }) {
646
+ const section = useEditableSection(options);
647
+ const {
648
+ isEditing,
649
+ isSaving,
650
+ formProps,
651
+ contentRef,
652
+ contentFormId,
653
+ editButtonProps,
654
+ saveButtonProps,
655
+ cancelButtonProps,
656
+ status,
657
+ statusProps,
658
+ confirmDiscard,
659
+ resolvedLabels,
660
+ contentStyle,
661
+ actionsClassName,
662
+ mode
663
+ } = section;
664
+ const actionButtons = !isEditing ? /* @__PURE__ */ jsxRuntime.jsx(Button, { variant: "outline", size: "sm", ...editButtonProps, children: resolvedLabels.edit }) : /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
665
+ /* @__PURE__ */ jsxRuntime.jsx(Button, { variant: "outline", size: "sm", ...cancelButtonProps, children: resolvedLabels.cancel }),
666
+ /* @__PURE__ */ jsxRuntime.jsx(
667
+ Button,
668
+ {
669
+ size: "sm",
670
+ type: "submit",
671
+ form: contentFormId,
672
+ disabled: saveButtonProps.disabled,
673
+ children: isSaving ? resolvedLabels.saving : resolvedLabels.save
674
+ }
675
+ )
676
+ ] });
677
+ const actions = /* @__PURE__ */ jsxRuntime.jsx("div", { "data-slot": "editable-section-actions", className: actionsClassName, children: actionButtons });
678
+ const content = isEditing ? /* @__PURE__ */ jsxRuntime.jsx(
679
+ "form",
680
+ {
681
+ id: contentFormId,
682
+ ref: contentRef,
683
+ "data-slot": "editable-section-content",
684
+ className: cn("sesionario-editable-section-content space-y-4"),
685
+ style: contentStyle,
686
+ ...formProps,
687
+ children: editView()
688
+ }
689
+ ) : /* @__PURE__ */ jsxRuntime.jsx(
690
+ "div",
691
+ {
692
+ ref: contentRef,
693
+ "data-slot": "editable-section-content",
694
+ className: "sesionario-editable-section-content",
695
+ style: contentStyle,
696
+ children: readView()
697
+ }
698
+ );
699
+ const statusNode = /* @__PURE__ */ jsxRuntime.jsx("span", { ...statusProps, "data-slot": "editable-section-status", children: status });
700
+ const dialog = /* @__PURE__ */ jsxRuntime.jsx(
701
+ ConfirmDestructiveDialog,
702
+ {
703
+ open: confirmDiscard.open,
704
+ onOpenChange: confirmDiscard.onOpenChange,
705
+ title: confirmDiscard.title,
706
+ description: confirmDiscard.description,
707
+ confirmLabel: confirmDiscard.confirmLabel,
708
+ cancelLabel: confirmDiscard.cancelLabel,
709
+ onConfirm: confirmDiscard.onConfirm
710
+ }
711
+ );
712
+ if (children) {
713
+ return /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
714
+ children({
715
+ actions,
716
+ content,
717
+ status: statusNode,
718
+ mode,
719
+ isSaving,
720
+ isEditing
721
+ }),
722
+ dialog
723
+ ] });
724
+ }
725
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { "data-slot": "editable-section", className: cn("space-y-4", className), children: [
726
+ actions,
727
+ content,
728
+ statusNode,
729
+ dialog
730
+ ] });
731
+ }
732
+ function DataList({ className, ...props }) {
733
+ return /* @__PURE__ */ jsxRuntime.jsx(
734
+ "dl",
735
+ {
736
+ "data-slot": "data-list",
737
+ className: cn("grid gap-4 sm:grid-cols-2", className),
738
+ ...props
739
+ }
740
+ );
741
+ }
742
+ function DataListItem({
743
+ label,
744
+ children,
745
+ empty = "\u2014",
746
+ className,
747
+ labelClassName,
748
+ valueClassName
749
+ }) {
750
+ const isEmpty = children == null || children === false || typeof children === "string" && children.trim().length === 0;
751
+ return /* @__PURE__ */ jsxRuntime.jsxs(
752
+ "div",
753
+ {
754
+ "data-slot": "data-list-item",
755
+ className: cn("flex flex-col gap-1.5", className),
756
+ children: [
757
+ /* @__PURE__ */ jsxRuntime.jsx(
758
+ "dt",
759
+ {
760
+ className: cn(
761
+ "text-sm leading-none font-medium peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
762
+ labelClassName
763
+ ),
764
+ children: label
765
+ }
766
+ ),
767
+ /* @__PURE__ */ jsxRuntime.jsx(
768
+ "dd",
769
+ {
770
+ className: cn(
771
+ "text-sm wrap-break-word",
772
+ isEmpty && "text-muted-foreground",
773
+ valueClassName
774
+ ),
775
+ children: isEmpty ? empty : children
776
+ }
777
+ )
778
+ ]
779
+ }
780
+ );
781
+ }
782
+ function FieldMessage({
783
+ id,
784
+ children,
785
+ className,
786
+ ...props
787
+ }) {
788
+ const hasMessage = children != null && children !== false && !(typeof children === "string" && children.trim().length === 0);
789
+ return /* @__PURE__ */ jsxRuntime.jsx(
790
+ "p",
791
+ {
792
+ id,
793
+ role: hasMessage ? "alert" : void 0,
794
+ "data-slot": "field-message",
795
+ className: cn("min-h-5 text-xs text-destructive", className),
796
+ ...props,
797
+ children: hasMessage ? children : "\xA0"
798
+ }
799
+ );
800
+ }
459
801
  var COLLAPSED_MAX_HEIGHT_REM = 11;
460
802
  function ExpandableText({
461
803
  children,
@@ -8340,8 +8682,12 @@ exports.BubbleGroup = BubbleGroup;
8340
8682
  exports.BubbleReactions = BubbleReactions;
8341
8683
  exports.ChatMessageList = ChatMessageList;
8342
8684
  exports.ConfirmDestructiveDialog = ConfirmDestructiveDialog;
8685
+ exports.DEFAULT_EDITABLE_SECTION_LABELS = DEFAULT_EDITABLE_SECTION_LABELS;
8686
+ exports.DataList = DataList;
8687
+ exports.DataListItem = DataListItem;
8343
8688
  exports.DropdownMenuItem = DropdownMenuItem;
8344
8689
  exports.DropdownMenuSeparator = DropdownMenuSeparator;
8690
+ exports.EditableSection = EditableSection;
8345
8691
  exports.EmojiInput = EmojiInput;
8346
8692
  exports.EmojiPicker = EmojiPicker;
8347
8693
  exports.EmojiPickerPopover = EmojiPickerPopover;
@@ -8356,6 +8702,7 @@ exports.EmptyTitle = EmptyTitle;
8356
8702
  exports.ErrorState = ErrorState;
8357
8703
  exports.ExpandableText = ExpandableText;
8358
8704
  exports.FeedVideoPlayer = FeedVideoPlayer;
8705
+ exports.FieldMessage = FieldMessage;
8359
8706
  exports.ImageLightbox = ImageLightbox;
8360
8707
  exports.LanguageContext = LanguageContext;
8361
8708
  exports.LanguageSwitcher = LanguageSwitcher;
@@ -8414,6 +8761,7 @@ exports.paymentTypeFromStripeBrand = paymentTypeFromStripeBrand;
8414
8761
  exports.shouldRetryLiveEdge404 = shouldRetryLiveEdge404;
8415
8762
  exports.useDebouncedValue = useDebouncedValue;
8416
8763
  exports.useDebouncedValueStrict = useDebouncedValueStrict;
8764
+ exports.useEditableSection = useEditableSection;
8417
8765
  exports.useFeedExclusivePlayback = useFeedExclusivePlayback;
8418
8766
  exports.useHlsPlayback = useHlsPlayback;
8419
8767
  exports.useLanguageContext = useLanguageContext;