@bison-lab/payload-blocks 3.2.0 → 3.4.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/dist/index.mjs CHANGED
@@ -19,30 +19,125 @@ function imageField(overrides = {}) {
19
19
  //#endregion
20
20
  //#region src/fields/link.ts
21
21
  /**
22
- * The three fields a call to action is made of, unwrapped.
22
+ * The import-map path of the link picker, `LinkField` in
23
+ * `@bison-lab/payload-blocks/admin`.
23
24
  *
24
- * `href` is plain text rather than a relationship to a `pages` row: a block in
25
- * this package has to work on a site whose page collection is named something
26
- * else, or which links out more often than in. Resolving an internal reference
27
- * is the site's job.
25
+ * It sits on the row that holds a link's `type`, `page` and `href`, and
26
+ * replaces those three inputs with one box: type to search published pages,
27
+ * or paste a URL. Referenced here by package specifier, like
28
+ * `MIN_ROWS_ARRAY_FIELD`; a site picks it up with `payload generate:importmap`.
29
+ * Until it does, Payload logs the missing entry and renders the row as
30
+ * nothing (a custom `Field` with no component behind it is an empty element,
31
+ * not the stock fields), so the step is part of adopting this release.
32
+ */
33
+ const LINK_FIELD = "@bison-lab/payload-blocks/admin#LinkField";
34
+ /**
35
+ * The package's resolver: a published, populated page is `/<slug>`; an
36
+ * external link is its `href` as typed.
37
+ *
38
+ * A page that did not populate is `null`: Payload hands the public read the
39
+ * bare id when the reader may not see the page, which is what an unpublished
40
+ * page looks like from the site. A populated page whose `_status` is `draft`
41
+ * is `null` too, for a read that can see drafts. A row from before links had
42
+ * a `type` is an external link by `linkTypeOf`, here, in the stock fields'
43
+ * conditions and in the picker alike: the site keeps rendering its `href`,
44
+ * the admin shows it as an External chip, and the site's migration to
45
+ * `type: external` only makes the stored row say what every reader already
46
+ * assumes.
47
+ */
48
+ function resolveLink(link) {
49
+ if (!link) return null;
50
+ if (linkTypeOf(link) === "page") {
51
+ const page = link.page;
52
+ if (!page || typeof page !== "object") return null;
53
+ if (page._status === "draft") return null;
54
+ return page.slug ? `/${page.slug}` : null;
55
+ }
56
+ return link.href ? link.href : null;
57
+ }
58
+ /**
59
+ * Which of the two stored destinations a row is using. `type` decides; a row
60
+ * with no `type` yet (saved before links had one) is read by what it holds,
61
+ * an `href` making it external, the same reading `resolveLink` and the picker
62
+ * make, so all three agree on every row.
63
+ */
64
+ function linkTypeOf(link) {
65
+ if (link?.type === "external") return "external";
66
+ if (link?.type === "page") return "page";
67
+ return link?.href ? "external" : "page";
68
+ }
69
+ const whenPage = (_data, siblingData) => linkTypeOf(siblingData) === "page";
70
+ const whenExternal = (_data, siblingData) => linkTypeOf(siblingData) === "external";
71
+ /**
72
+ * Where a link goes, as stored: `type`, `page` and `href` in one row.
73
+ *
74
+ * The row carries the picker (`LINK_FIELD`), which replaces all three inputs
75
+ * with one box. The stored fields are the website template's shape and stay
76
+ * editable on their own: `page` shows for a page link, `href` for an external
77
+ * one, and `required` binds whichever is active, since Payload skips
78
+ * validation on a field whose condition is false. `page` offers published
79
+ * rows only, and Payload re-checks that on publish, so a page that was
80
+ * unpublished after being picked has to be picked again or republished.
81
+ */
82
+ function linkDestinationFields({ required = false, pagesCollection = "pages" } = {}) {
83
+ return [{
84
+ type: "row",
85
+ admin: { components: { Field: LINK_FIELD } },
86
+ fields: [
87
+ {
88
+ name: "type",
89
+ type: "radio",
90
+ label: "Goes to",
91
+ defaultValue: "page",
92
+ options: [{
93
+ label: "Page",
94
+ value: "page"
95
+ }, {
96
+ label: "URL",
97
+ value: "external"
98
+ }],
99
+ admin: { layout: "horizontal" }
100
+ },
101
+ {
102
+ name: "page",
103
+ type: "relationship",
104
+ relationTo: pagesCollection,
105
+ required,
106
+ filterOptions: { _status: { equals: "published" } },
107
+ admin: { condition: whenPage }
108
+ },
109
+ {
110
+ name: "href",
111
+ type: "text",
112
+ label: "URL",
113
+ required,
114
+ admin: {
115
+ condition: whenExternal,
116
+ description: "A full URL (\"https://example.com\"), \"mailto:\" or \"tel:\", or a site path (\"/contact\")."
117
+ }
118
+ }
119
+ ]
120
+ }];
121
+ }
122
+ /**
123
+ * The fields a call to action is made of, unwrapped: the destination row,
124
+ * then the label and the new-tab flag.
28
125
  *
29
126
  * `newTab` drives `target="_blank"` *and* the matching `rel`, which is why no
30
127
  * renderer takes one without the other.
31
128
  */
32
- function linkFields({ required = false, labelFieldLabel = "Label" } = {}) {
129
+ function linkFields({ required = false, labelFieldLabel = "Label", pagesCollection } = {}) {
33
130
  return [
131
+ ...linkDestinationFields({
132
+ required,
133
+ pagesCollection
134
+ }),
34
135
  {
35
136
  name: "label",
36
137
  type: "text",
37
138
  label: labelFieldLabel,
38
139
  required
39
140
  },
40
- {
41
- name: "href",
42
- type: "text",
43
- required,
44
- admin: { description: "A site path (\"/contact\") or a full URL (\"https://example.com\")." }
45
- },
46
141
  {
47
142
  name: "newTab",
48
143
  type: "checkbox",
@@ -323,21 +418,27 @@ const ProcessStepsBlock = {
323
418
  * puts the least-optional copy behind a click. The field names match the
324
419
  * `@bison-lab/ui` prop names so the renderers stay a pass-through.
325
420
  */
326
- function headingFields({ required = true, eyebrowDescription = "Small label above the heading. Leave empty to hide the row." } = {}) {
421
+ function headingFields({ required = true, eyebrowDescription = "Small label above the heading. Leave empty to hide the row.", condition } = {}) {
422
+ const when = condition ? { condition } : {};
327
423
  return [
328
424
  {
329
425
  name: "eyebrow",
330
426
  type: "text",
331
- admin: { description: eyebrowDescription }
427
+ admin: {
428
+ description: eyebrowDescription,
429
+ ...when
430
+ }
332
431
  },
333
432
  {
334
433
  name: "title",
335
434
  type: "text",
336
- required
435
+ required,
436
+ admin: when
337
437
  },
338
438
  {
339
439
  name: "description",
340
- type: "textarea"
440
+ type: "textarea",
441
+ admin: when
341
442
  }
342
443
  ];
343
444
  }
@@ -389,10 +490,7 @@ const FaqColumnsBlock = {
389
490
  name: "linkText",
390
491
  type: "text"
391
492
  },
392
- {
393
- name: "href",
394
- type: "text"
395
- },
493
+ ...linkDestinationFields(),
396
494
  {
397
495
  name: "newTab",
398
496
  type: "checkbox",
@@ -641,6 +739,177 @@ const NapBlock = {
641
739
  ]
642
740
  };
643
741
  //#endregion
742
+ //#region src/fields/row-limits.ts
743
+ /**
744
+ * A custom `validate` replaces Payload's stock array check, so any array
745
+ * with its own rule re-applies the row limits first or `minRows` and
746
+ * `maxRows` stop being enforced. Same messages as the stock check, through
747
+ * the request's translator when the admin supplies one.
748
+ */
749
+ function rowLimits(value, { minRows, maxRows, required, req }) {
750
+ const count = value?.length ?? 0;
751
+ const t = req?.t;
752
+ if (required && count === 0) return t ? t("validation:required") : "This field is required.";
753
+ if (minRows && count < minRows) return t ? t("validation:requiresAtLeast", {
754
+ count: minRows,
755
+ label: t("general:rows")
756
+ }) : `This field requires at least ${minRows} rows.`;
757
+ if (maxRows && count > maxRows) return t ? t("validation:requiresNoMoreThan", {
758
+ count: maxRows,
759
+ label: t("general:rows")
760
+ }) : `This field requires no more than ${maxRows} rows.`;
761
+ return true;
762
+ }
763
+ //#endregion
764
+ //#region src/blocks/stats-band/config.ts
765
+ /**
766
+ * The rule on `wide`: on phones the band is two per row, so only an odd
767
+ * count has a stat on a row of its own, only one stat can take it, and it
768
+ * has to be a stat that starts a row (the 1st, 3rd or 5th), since a full-row
769
+ * cell beside another cell would leave a hole. The array's own `validate`,
770
+ * so the editor sees the message as they build, and Payload runs it again on
771
+ * publish.
772
+ */
773
+ const validateWideFlag = (value, options) => {
774
+ const limits = rowLimits(value, options);
775
+ if (limits !== true) return limits;
776
+ const rows = value ?? [];
777
+ const wide = rows.flatMap((row, i) => row?.wide ? [i] : []);
778
+ if (wide.length > 1) return "Only one stat can be full width on phones.";
779
+ if (wide.length === 0) return true;
780
+ if (rows.length % 2 === 0) return "Full width on phones only applies to an odd number of stats. With an even count every row is already full.";
781
+ if (wide[0] % 2 !== 0) return "Only a stat that starts a phone row can be full width: the 1st, 3rd or 5th. Move it up or down one place, or flag another.";
782
+ return true;
783
+ };
784
+ /**
785
+ * An overlapping band is headless: it floats across the seam as a card, and a
786
+ * heading pulled up into the block above would land on the wrong surface.
787
+ * The heading fields disappear from the admin the moment `overlap` is on,
788
+ * and the renderer ignores them if they were filled in first.
789
+ */
790
+ const unlessOverlap = (_data, siblingData) => !siblingData?.overlap;
791
+ /**
792
+ * A strip of figures: the facts a visitor should take in at a glance.
793
+ *
794
+ * The count, the order and the desktop column count are the editor's. The
795
+ * phone and tablet layouts are the library's: two per row and three per row,
796
+ * with `wide` naming the one stat that takes a full phone row when the count
797
+ * is odd.
798
+ */
799
+ const StatsBandBlock = {
800
+ slug: "statsBand",
801
+ interfaceName: "StatsBandBlock",
802
+ labels: {
803
+ singular: "Stats Band",
804
+ plural: "Stats Bands"
805
+ },
806
+ fields: [
807
+ ...headingFields({
808
+ required: false,
809
+ eyebrowDescription: "Small label above the heading. The heading and eyebrow are optional; without them the band stands alone.",
810
+ condition: unlessOverlap
811
+ }),
812
+ {
813
+ name: "items",
814
+ type: "array",
815
+ required: true,
816
+ minRows: 2,
817
+ maxRows: 6,
818
+ defaultValue: emptyRows(2),
819
+ labels: {
820
+ singular: "Stat",
821
+ plural: "Stats"
822
+ },
823
+ admin: { components: { Field: MIN_ROWS_ARRAY_FIELD } },
824
+ validate: validateWideFlag,
825
+ fields: [
826
+ {
827
+ name: "value",
828
+ type: "text",
829
+ required: true,
830
+ admin: { description: "The figure, as it should read: \"98%\", \"12,000+\", \"L1–S1\"." }
831
+ },
832
+ {
833
+ name: "label",
834
+ type: "textarea",
835
+ required: true
836
+ },
837
+ {
838
+ name: "href",
839
+ type: "text",
840
+ admin: { description: "Optional. Makes the whole stat a link: a site path (\"/outcomes\") or a full URL." }
841
+ },
842
+ {
843
+ name: "wide",
844
+ type: "checkbox",
845
+ label: "Full width on phones",
846
+ defaultValue: false,
847
+ admin: { description: "Phones show two per row. With an odd number of stats, this one takes a row to itself. Only the 1st, 3rd or 5th stat can." }
848
+ }
849
+ ]
850
+ },
851
+ {
852
+ name: "columns",
853
+ type: "select",
854
+ defaultValue: "4",
855
+ options: [
856
+ {
857
+ label: "2",
858
+ value: "2"
859
+ },
860
+ {
861
+ label: "3",
862
+ value: "3"
863
+ },
864
+ {
865
+ label: "4",
866
+ value: "4"
867
+ },
868
+ {
869
+ label: "5",
870
+ value: "5"
871
+ },
872
+ {
873
+ label: "6",
874
+ value: "6"
875
+ }
876
+ ],
877
+ admin: { description: "From large screens up, and never more than there are stats. Phones show two per row and tablets three." }
878
+ },
879
+ {
880
+ name: "tone",
881
+ type: "select",
882
+ defaultValue: "card",
883
+ options: [{
884
+ label: "Card (bordered, raised)",
885
+ value: "card"
886
+ }, {
887
+ label: "Plain (dividers only)",
888
+ value: "plain"
889
+ }]
890
+ },
891
+ {
892
+ name: "align",
893
+ type: "select",
894
+ defaultValue: "start",
895
+ options: [{
896
+ label: "Left",
897
+ value: "start"
898
+ }, {
899
+ label: "Centred",
900
+ value: "center"
901
+ }]
902
+ },
903
+ {
904
+ name: "overlap",
905
+ type: "checkbox",
906
+ label: "Float over the seam with the block above",
907
+ defaultValue: false,
908
+ admin: { description: "The band sits across the join between the block above and the block below. Both make room for it automatically. A floating band is headless: the heading fields above are hidden while this is on." }
909
+ }
910
+ ]
911
+ };
912
+ //#endregion
644
913
  //#region src/blocks/mega-menu/rem-width.ts
645
914
  /**
646
915
  * The one rule for a typed panel width, shared by the config's validator and
@@ -676,25 +945,6 @@ const TWELFTHS = {
676
945
  "100": 12
677
946
  };
678
947
  /**
679
- * A custom `validate` replaces Payload's stock array check, so the row limits
680
- * are re-applied here or five columns would pass. Same messages as the stock
681
- * check, through the request's translator when the admin supplies one.
682
- */
683
- function rowLimits(value, { minRows, maxRows, required, req }) {
684
- const count = value?.length ?? 0;
685
- const t = req?.t;
686
- if (required && count === 0) return t ? t("validation:required") : "This field is required.";
687
- if (minRows && count < minRows) return t ? t("validation:requiresAtLeast", {
688
- count: minRows,
689
- label: t("general:rows")
690
- }) : `This field requires at least ${minRows} rows.`;
691
- if (maxRows && count > maxRows) return t ? t("validation:requiresNoMoreThan", {
692
- count: maxRows,
693
- label: t("general:rows")
694
- }) : `This field requires no more than ${maxRows} rows.`;
695
- return true;
696
- }
697
- /**
698
948
  * The rule on `columns`: read as fractions, the widths must total 100%. It is
699
949
  * the array's own `validate`, so the editor sees the message as they build,
700
950
  * and Payload runs field validation again on publish (only draft saves skip
@@ -990,6 +1240,7 @@ const faqColumnsSample = {
990
1240
  cta: {
991
1241
  title: "Still have questions?",
992
1242
  linkText: "Call the front desk",
1243
+ type: "external",
993
1244
  href: "tel:+13035550142",
994
1245
  newTab: false
995
1246
  },
@@ -1044,12 +1295,19 @@ const heroSample = {
1044
1295
  alt: "A bright, open treatment room"
1045
1296
  }),
1046
1297
  links: [{
1298
+ type: "page",
1299
+ page: {
1300
+ id: "sample-page-book",
1301
+ title: "Book an assessment",
1302
+ slug: "book",
1303
+ _status: "published"
1304
+ },
1047
1305
  label: "Book an assessment",
1048
- href: "/book",
1049
1306
  newTab: false
1050
1307
  }, {
1051
- label: "See our approach",
1308
+ type: "external",
1052
1309
  href: "https://example.com/approach",
1310
+ label: "See our approach",
1053
1311
  newTab: true
1054
1312
  }]
1055
1313
  };
@@ -1080,7 +1338,10 @@ const megaMenuSampleOptions = {
1080
1338
  * Spinal Simplicity's Patients panel as a saved row: two featured territories
1081
1339
  * in a 75% column, two rail sections in a divided 25% column, overview and
1082
1340
  * CTA. Every field is set at least once; the Advanced fields are set but off,
1083
- * so the percent widths are what renders.
1341
+ * so the percent widths are what renders. Most links are site paths typed as
1342
+ * external links; Testimonials and the overview are page links carried
1343
+ * inline, as `depth: 1` would populate them, so the preview needs no pages
1344
+ * collection.
1084
1345
  */
1085
1346
  const megaMenuSample = {
1086
1347
  id: "sample-mega-menu",
@@ -1102,6 +1363,7 @@ const megaMenuSample = {
1102
1363
  links: [{
1103
1364
  id: "lumbar",
1104
1365
  label: "Low Back Pain",
1366
+ type: "external",
1105
1367
  href: "/patients/low-back-pain",
1106
1368
  newTab: false,
1107
1369
  description: "Persistent low back pain from lumbar instability, often with leg pain that limits standing or walking.",
@@ -1109,6 +1371,7 @@ const megaMenuSample = {
1109
1371
  }, {
1110
1372
  id: "si",
1111
1373
  label: "Hip Pain",
1374
+ type: "external",
1112
1375
  href: "/patients/hip-pain",
1113
1376
  newTab: false,
1114
1377
  description: "Pain centered on the sacroiliac joint, often worse with sitting or climbing stairs.",
@@ -1129,18 +1392,26 @@ const megaMenuSample = {
1129
1392
  {
1130
1393
  id: "stories",
1131
1394
  label: "Testimonials",
1132
- href: "/patients/stories",
1395
+ type: "page",
1396
+ page: {
1397
+ id: "sample-page-stories",
1398
+ title: "Testimonials",
1399
+ slug: "patients/stories",
1400
+ _status: "published"
1401
+ },
1133
1402
  newTab: false
1134
1403
  },
1135
1404
  {
1136
1405
  id: "research",
1137
1406
  label: "Research",
1407
+ type: "external",
1138
1408
  href: "/patients/research",
1139
1409
  newTab: false
1140
1410
  },
1141
1411
  {
1142
1412
  id: "path",
1143
1413
  label: "Path to Relief",
1414
+ type: "external",
1144
1415
  href: "/patients/path-to-relief",
1145
1416
  newTab: false
1146
1417
  }
@@ -1153,6 +1424,7 @@ const megaMenuSample = {
1153
1424
  links: [{
1154
1425
  id: "faqs",
1155
1426
  label: "FAQs",
1427
+ type: "external",
1156
1428
  href: "/patients/faqs",
1157
1429
  newTab: false,
1158
1430
  description: "Short answers to the questions patients ask first.",
@@ -1160,6 +1432,7 @@ const megaMenuSample = {
1160
1432
  }, {
1161
1433
  id: "contact",
1162
1434
  label: "Contact",
1435
+ type: "external",
1163
1436
  href: "https://example.com/contact",
1164
1437
  newTab: true,
1165
1438
  icon: "mail"
@@ -1169,11 +1442,18 @@ const megaMenuSample = {
1169
1442
  footer: {
1170
1443
  overview: {
1171
1444
  label: "All patient resources",
1172
- href: "/patients",
1445
+ type: "page",
1446
+ page: {
1447
+ id: "sample-page-patients",
1448
+ title: "Patients",
1449
+ slug: "patients",
1450
+ _status: "published"
1451
+ },
1173
1452
  newTab: false
1174
1453
  },
1175
1454
  cta: {
1176
1455
  label: "Find a Doctor",
1456
+ type: "external",
1177
1457
  href: "/find-a-doctor",
1178
1458
  newTab: false
1179
1459
  }
@@ -1187,6 +1467,7 @@ const navLinkSample = {
1187
1467
  id: "sample-nav-link",
1188
1468
  blockType: "navLink",
1189
1469
  label: "Contact",
1470
+ type: "external",
1190
1471
  href: "/contact",
1191
1472
  newTab: false
1192
1473
  };
@@ -1422,6 +1703,50 @@ const showcasePanelsSample = {
1422
1703
  defaultActiveIndex: 0
1423
1704
  };
1424
1705
  //#endregion
1706
+ //#region src/blocks/stats-band/sample.ts
1707
+ /**
1708
+ * Four figures, the count the strip was designed around: one row on a
1709
+ * desktop, two by two on a tablet and a phone. `wide` is set (to `false`) so
1710
+ * the preview exercises the field, and an even count is where it is ignored.
1711
+ * `overlap` is off so the heading shows: a floating band is headless, and a
1712
+ * preview page has no hero for it to float over.
1713
+ */
1714
+ const statsBandSample = {
1715
+ id: "sample-stats-band",
1716
+ blockType: "statsBand",
1717
+ eyebrow: "At a glance",
1718
+ title: "The clinic in numbers",
1719
+ description: "What a year looks like across our two rooms, counted from the front desk rather than estimated.",
1720
+ items: [
1721
+ {
1722
+ id: "sample-stat-pain",
1723
+ value: "94%",
1724
+ label: "of patients report less pain by their sixth session.",
1725
+ href: "/outcomes",
1726
+ wide: false
1727
+ },
1728
+ {
1729
+ id: "sample-stat-visits",
1730
+ value: "4,200+",
1731
+ label: "appointments a year across two treatment rooms."
1732
+ },
1733
+ {
1734
+ id: "sample-stat-wait",
1735
+ value: "15 min",
1736
+ label: "average wait from the front desk to the treatment room."
1737
+ },
1738
+ {
1739
+ id: "sample-stat-years",
1740
+ value: "12",
1741
+ label: "years in the same building, on the same street."
1742
+ }
1743
+ ],
1744
+ columns: "4",
1745
+ tone: "card",
1746
+ align: "start",
1747
+ overlap: false
1748
+ };
1749
+ //#endregion
1425
1750
  //#region src/blocks/testimonial-masonry/sample.ts
1426
1751
  function avatar(name) {
1427
1752
  return sampleImage({
@@ -1512,18 +1837,20 @@ const blockSamples = {
1512
1837
  }
1513
1838
  ],
1514
1839
  link: {
1515
- label: "Read every review",
1840
+ type: "external",
1516
1841
  href: "https://example.com/reviews",
1842
+ label: "Read every review",
1517
1843
  newTab: true
1518
1844
  },
1519
1845
  minItemsForFade: 6,
1520
1846
  maxVisibleRows: 2
1521
1847
  },
1522
1848
  nap: napSample,
1849
+ statsBand: statsBandSample,
1523
1850
  megaMenu: megaMenuSample,
1524
1851
  navLink: navLinkSample
1525
1852
  };
1526
1853
  //#endregion
1527
- export { FaqColumnsBlock, HeroBlock, LinkBlock, MEGA_MENU_WIDTHS, MIN_ROWS_ARRAY_FIELD, NapBlock, ProcessStepsBlock, RichTextBlock, ShowcasePanelsBlock, TestimonialMasonryBlock, blockSamples, emptyRows, headingFields, imageField, linkField, linkFields, megaMenuBlock, megaMenuSampleOptions, resolveMedia, sampleImage, validateColumnWidths, validateRemWidth };
1854
+ export { FaqColumnsBlock, HeroBlock, LINK_FIELD, LinkBlock, MEGA_MENU_WIDTHS, MIN_ROWS_ARRAY_FIELD, NapBlock, ProcessStepsBlock, RichTextBlock, ShowcasePanelsBlock, StatsBandBlock, TestimonialMasonryBlock, blockSamples, emptyRows, headingFields, imageField, linkDestinationFields, linkField, linkFields, linkTypeOf, megaMenuBlock, megaMenuSampleOptions, resolveLink, resolveMedia, sampleImage, validateColumnWidths, validateRemWidth };
1528
1855
 
1529
1856
  //# sourceMappingURL=index.mjs.map