@thewhatmatters/wmds 0.4.7 → 0.4.9

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 (186) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/CONSUMING.md +4 -1
  3. package/bin/wmds-check.mjs +8 -4
  4. package/dist/components/atoms/TextLink/TextLink.d.ts +9 -3
  5. package/dist/components/atoms/TextLink/TextLink.d.ts.map +1 -1
  6. package/dist/components/atoms/TextLink/TextLink.js +22 -17
  7. package/dist/components/molecules/IndexList/IndexList.d.ts +6 -3
  8. package/dist/components/molecules/IndexList/IndexList.d.ts.map +1 -1
  9. package/dist/components/molecules/IndexList/IndexList.js +22 -21
  10. package/dist/components/molecules/LinkTile/LinkTile.d.ts +45 -0
  11. package/dist/components/molecules/LinkTile/LinkTile.d.ts.map +1 -0
  12. package/dist/components/molecules/LinkTile/LinkTile.js +121 -0
  13. package/dist/components/molecules/LinkTile/linkTileStyles.d.ts +37 -0
  14. package/dist/components/molecules/LinkTile/linkTileStyles.d.ts.map +1 -0
  15. package/dist/components/molecules/LinkTile/linkTileStyles.js +8 -0
  16. package/dist/components/molecules/TileGrid/TileGrid.d.ts +42 -0
  17. package/dist/components/molecules/TileGrid/TileGrid.d.ts.map +1 -0
  18. package/dist/components/molecules/TileGrid/TileGrid.js +94 -0
  19. package/dist/components/molecules/TileGrid/tileGridMath.d.ts +8 -0
  20. package/dist/components/molecules/TileGrid/tileGridMath.d.ts.map +1 -0
  21. package/dist/components/molecules/TileGrid/tileGridMath.js +20 -0
  22. package/dist/components/molecules/TileGrid/tileGridStyles.d.ts +33 -0
  23. package/dist/components/molecules/TileGrid/tileGridStyles.d.ts.map +1 -0
  24. package/dist/components/molecules/TileGrid/tileGridStyles.js +16 -0
  25. package/dist/components/organisms/Dialog/Dialog.d.ts +12 -2
  26. package/dist/components/organisms/Dialog/Dialog.d.ts.map +1 -1
  27. package/dist/components/organisms/Dialog/Dialog.js +46 -44
  28. package/dist/components/organisms/Dialog/OverlayPanelHeader.d.ts +3 -1
  29. package/dist/components/organisms/Dialog/OverlayPanelHeader.d.ts.map +1 -1
  30. package/dist/components/organisms/Dialog/OverlayPanelHeader.js +20 -17
  31. package/dist/components/organisms/Dialog/dialogStyles.d.ts +5 -1
  32. package/dist/components/organisms/Dialog/dialogStyles.d.ts.map +1 -1
  33. package/dist/components/organisms/Dialog/dialogStyles.js +13 -11
  34. package/dist/components/organisms/FilterPanel/FilterPanel.d.ts +54 -0
  35. package/dist/components/organisms/FilterPanel/FilterPanel.d.ts.map +1 -0
  36. package/dist/components/organisms/FilterPanel/FilterPanel.js +137 -0
  37. package/dist/components/organisms/FilterPanel/filterSelection.d.ts +44 -0
  38. package/dist/components/organisms/FilterPanel/filterSelection.d.ts.map +1 -0
  39. package/dist/components/organisms/FilterPanel/filterSelection.js +35 -0
  40. package/dist/index.d.ts +3 -0
  41. package/dist/index.d.ts.map +1 -1
  42. package/dist/index.js +73 -67
  43. package/dist/lib/compositionAudit.d.ts.map +1 -1
  44. package/dist/lib/typography.d.ts +2 -2
  45. package/dist/lib/typography.d.ts.map +1 -1
  46. package/dist/lib/typography.js +11 -0
  47. package/dist/styles.css +1 -1
  48. package/dist/typography.css +16 -0
  49. package/docs/components.md +10 -7
  50. package/docs/exports.json +43 -2
  51. package/docs/patterns/components-accordion--flush.tsx +1 -1
  52. package/docs/patterns/components-avatar--dropdown-row.tsx +1 -1
  53. package/docs/patterns/components-badge--outline-tags.tsx +1 -1
  54. package/docs/patterns/components-badge--with-avatar.tsx +1 -1
  55. package/docs/patterns/components-breadcrumb--editorial.tsx +1 -1
  56. package/docs/patterns/components-breadcrumb--long-path.tsx +1 -1
  57. package/docs/patterns/components-breadcrumb--path.tsx +1 -1
  58. package/docs/patterns/components-breadcrumb--router-links.tsx +1 -1
  59. package/docs/patterns/components-button-button--copy-button.tsx +1 -1
  60. package/docs/patterns/components-button-button--destructive-action.tsx +1 -1
  61. package/docs/patterns/components-button-button--external-link.tsx +1 -1
  62. package/docs/patterns/components-button-button--ghost-action.tsx +1 -1
  63. package/docs/patterns/components-button-button--inverse-action.tsx +1 -1
  64. package/docs/patterns/components-button-button--link-render.tsx +1 -1
  65. package/docs/patterns/components-button-button--multi-state-badge.tsx +1 -1
  66. package/docs/patterns/components-button-button--outline-mono.tsx +1 -1
  67. package/docs/patterns/components-button-button--primary-action.tsx +1 -1
  68. package/docs/patterns/components-button-button--row-hug.tsx +1 -1
  69. package/docs/patterns/components-button-button--row-layout.tsx +1 -1
  70. package/docs/patterns/components-button-button--secondary-action.tsx +1 -1
  71. package/docs/patterns/components-button-button--suggestion-pills.tsx +1 -1
  72. package/docs/patterns/components-button-button--with-count.tsx +1 -1
  73. package/docs/patterns/components-button-button--with-icon.tsx +1 -1
  74. package/docs/patterns/components-button-floatingactionbutton--vertical-actions.tsx +1 -1
  75. package/docs/patterns/components-calembed--pattern-skip-in-footer.tsx +1 -1
  76. package/docs/patterns/components-card-card--body-slot-occupancy-history.tsx +1 -1
  77. package/docs/patterns/components-card-card--headerless-media.tsx +1 -1
  78. package/docs/patterns/components-card-card--outlined-layout.tsx +1 -1
  79. package/docs/patterns/components-card-card--pinned-height.tsx +1 -1
  80. package/docs/patterns/components-card-selectablecard--selectable-cards-pattern.tsx +1 -1
  81. package/docs/patterns/components-carousel--image-carousel.tsx +1 -1
  82. package/docs/patterns/components-chart--audience-activity-heatmap.tsx +1 -1
  83. package/docs/patterns/components-chart--cartesian-no-data-gaps.tsx +1 -1
  84. package/docs/patterns/components-chart--occupancy-history-in-card.tsx +1 -1
  85. package/docs/patterns/components-chart--occupancy-in-card.tsx +1 -1
  86. package/docs/patterns/components-chart--post-reach-distribution.tsx +1 -1
  87. package/docs/patterns/components-chart--ranked-audience-breakdown.tsx +1 -1
  88. package/docs/patterns/components-chart--unit-grid-composition.tsx +1 -1
  89. package/docs/patterns/components-chatdock--pattern-chat-dock.tsx +1 -1
  90. package/docs/patterns/components-chatdock--pattern-start-project-gate.tsx +1 -1
  91. package/docs/patterns/components-chatqa--pattern-qa-with-reply.tsx +1 -1
  92. package/docs/patterns/components-checkbox-checkbox--default.tsx +1 -1
  93. package/docs/patterns/components-checkbox-checkbox--with-count.tsx +1 -1
  94. package/docs/patterns/components-checkbox-checkbox--with-description.tsx +1 -1
  95. package/docs/patterns/components-checkbox-checkboxgroup--default.tsx +1 -1
  96. package/docs/patterns/components-checkbox-checkboxgroup--option-counts.tsx +1 -1
  97. package/docs/patterns/components-descriptionlist--details.tsx +1 -1
  98. package/docs/patterns/components-descriptionlist--post-metadata.tsx +1 -1
  99. package/docs/patterns/components-dialog--alert-dialog-destructive.tsx +1 -1
  100. package/docs/patterns/components-dialog--default-dialog.tsx +1 -1
  101. package/docs/patterns/components-dialog--form-dialog.tsx +1 -1
  102. package/docs/patterns/components-displaycontrols--page-utilities.tsx +1 -1
  103. package/docs/patterns/components-field--horizontal-input.tsx +1 -1
  104. package/docs/patterns/components-field--horizontal-text-area.tsx +1 -1
  105. package/docs/patterns/components-field--multi-control.tsx +1 -1
  106. package/docs/patterns/components-filterpanel--panel.tsx +55 -0
  107. package/docs/patterns/components-footerreveal--marketing-hero-pattern.tsx +1 -1
  108. package/docs/patterns/components-footerreveal--marketing-hero-ruled-pattern.tsx +1 -1
  109. package/docs/patterns/components-footerreveal--marketing-hero-with-gallery-intro.tsx +1 -1
  110. package/docs/patterns/components-footerreveal--marketing-page-pattern.tsx +1 -1
  111. package/docs/patterns/components-footerreveal--ruled-grid-footer-pattern.tsx +1 -1
  112. package/docs/patterns/components-footerreveal--sequenced-marketing-hero.tsx +1 -1
  113. package/docs/patterns/components-herointro--two-line-intro.tsx +1 -1
  114. package/docs/patterns/components-herotilestack--marketing-hero-pattern.tsx +1 -1
  115. package/docs/patterns/components-herotilestack--marketing-hero-text-sequence.tsx +1 -1
  116. package/docs/patterns/components-herotilestack--marketing-hero-with-gallery-intro.tsx +1 -1
  117. package/docs/patterns/components-indexlist--post-index.tsx +1 -1
  118. package/docs/patterns/components-intakeconfirmation--booked-pattern.tsx +1 -1
  119. package/docs/patterns/components-intakeconfirmation--emailed-pattern.tsx +1 -1
  120. package/docs/patterns/components-intakeform--about-you-pattern.tsx +1 -1
  121. package/docs/patterns/components-intakeform--short-form-pattern.tsx +1 -1
  122. package/docs/patterns/components-intakemodal--intake-modal-pattern.tsx +1 -1
  123. package/docs/patterns/components-kbd--command-row.tsx +1 -1
  124. package/docs/patterns/components-kbd--numbered-choices.tsx +1 -1
  125. package/docs/patterns/components-kbd--shortcut.tsx +1 -1
  126. package/docs/patterns/components-linktile--resource-tile.tsx +33 -0
  127. package/docs/patterns/components-linktile--router-link.tsx +25 -0
  128. package/docs/patterns/components-moremenu--action-menu.tsx +1 -1
  129. package/docs/patterns/components-moremenu--in-card-header.tsx +1 -1
  130. package/docs/patterns/components-navlist--side-nav-settings.tsx +1 -1
  131. package/docs/patterns/components-pageheader--app-header-pattern.tsx +1 -1
  132. package/docs/patterns/components-pageheader--page-section-header.tsx +1 -1
  133. package/docs/patterns/components-pageheader--toolbar-header.tsx +1 -1
  134. package/docs/patterns/components-panel--end-detail-rail.tsx +1 -1
  135. package/docs/patterns/components-pillgroup--pill-group-pattern.tsx +1 -1
  136. package/docs/patterns/components-promptbar--pattern-prompt-bar.tsx +1 -1
  137. package/docs/patterns/components-prose--article-body.tsx +1 -1
  138. package/docs/patterns/components-radio-radiogroup--default.tsx +1 -1
  139. package/docs/patterns/components-scrollhorizontal--project-gallery-pattern.tsx +1 -1
  140. package/docs/patterns/components-scrollhorizontal--with-intro.tsx +1 -1
  141. package/docs/patterns/components-sectioncaption--column-caption.tsx +1 -1
  142. package/docs/patterns/components-sectioncaption--with-action.tsx +1 -1
  143. package/docs/patterns/components-sheet--bottom-sheet.tsx +1 -1
  144. package/docs/patterns/components-sheet--end-sheet.tsx +1 -1
  145. package/docs/patterns/components-sitenav--brand-and-cta.tsx +1 -1
  146. package/docs/patterns/components-sitenav--centered-links.tsx +1 -1
  147. package/docs/patterns/components-sitenav--compact.tsx +1 -1
  148. package/docs/patterns/components-sitenav--marketing-header.tsx +1 -1
  149. package/docs/patterns/components-sitenav--mega-menu.tsx +1 -1
  150. package/docs/patterns/components-stat--insights-row.tsx +1 -1
  151. package/docs/patterns/components-stepprogress--step-progress-pattern.tsx +1 -1
  152. package/docs/patterns/components-switch--default.tsx +1 -1
  153. package/docs/patterns/components-switch--settings-row.tsx +1 -1
  154. package/docs/patterns/components-tab--settings-pages.tsx +1 -1
  155. package/docs/patterns/components-taskrows--action-details.tsx +1 -1
  156. package/docs/patterns/components-taskrows--capsules.tsx +1 -1
  157. package/docs/patterns/components-taskrows--card-with-status-rows.tsx +1 -1
  158. package/docs/patterns/components-taskrows--controlled-expand.tsx +1 -1
  159. package/docs/patterns/components-taskrows--custom-slots.tsx +1 -1
  160. package/docs/patterns/components-taskrows--status-list.tsx +1 -1
  161. package/docs/patterns/components-taskrows--tag-chips.tsx +1 -1
  162. package/docs/patterns/components-textlink--external-destination.tsx +1 -1
  163. package/docs/patterns/components-textlink--inline-prose.tsx +1 -1
  164. package/docs/patterns/components-textlink--quiet-title.tsx +1 -1
  165. package/docs/patterns/components-textlink--supporting-copy.tsx +1 -1
  166. package/docs/patterns/components-textsequence--default.tsx +1 -1
  167. package/docs/patterns/components-textsequence--reduced-motion.tsx +1 -1
  168. package/docs/patterns/components-textsequence--shapes.tsx +1 -1
  169. package/docs/patterns/components-tilegrid--masonry.tsx +44 -0
  170. package/docs/patterns/components-toast--stacked-notifications.tsx +1 -1
  171. package/docs/patterns/components-tooltip--controlled.tsx +1 -1
  172. package/docs/patterns/components-tooltip--disabled-control.tsx +1 -1
  173. package/docs/patterns/components-tooltip--icon-action.tsx +1 -1
  174. package/docs/patterns/components-tooltip--keyboard-shortcut.tsx +1 -1
  175. package/docs/patterns/components-tooltip--placement.tsx +1 -1
  176. package/docs/patterns/foundations-scroll-fade--horizontal-rail.tsx +1 -1
  177. package/docs/patterns/foundations-scroll-fade--overflowing-list.tsx +1 -1
  178. package/docs/patterns/guides-chart-explorations--comparison-gallery.tsx +1 -1
  179. package/docs/patterns/guides-filter-panel--filtered-grid.tsx +182 -0
  180. package/docs/patterns/guides-filter-panel--filtered-index.tsx +68 -153
  181. package/docs/patterns/guides-overlay-flows--notification-preferences.tsx +1 -1
  182. package/docs/patterns/guides-post-page--post-page.tsx +117 -56
  183. package/docs/patterns/guides-profile-typography--editorial-profile.tsx +1 -1
  184. package/docs/patterns/guides-resource-detail--detail.tsx +270 -0
  185. package/docs/patterns/index.json +49 -1
  186. package/package.json +1 -1
@@ -0,0 +1,44 @@
1
+ // @thewhatmatters/wmds@0.4.9 · Pattern — tile grid
2
+ // Storybook: Components/TileGrid → Pattern — tile grid (?path=/story/components-tilegrid--masonry)
3
+ // Show code — copy verbatim and keep this header; upgrades find pasted patterns by it.
4
+
5
+ import { LinkTile, TileGrid } from "@thewhatmatters/wmds";
6
+
7
+ export interface ResourceTileItem {
8
+ id: string;
9
+ title: string;
10
+ href: string;
11
+ /** Another site: the tile opens it in a new tab. */
12
+ external?: boolean;
13
+ /** One line under the title, for example the domain. */
14
+ meta?: string;
15
+ /** The preview. Its width and height hold the tile's space before the image loads. */
16
+ image: { src: string; alt: string; width: number; height: number };
17
+ }
18
+
19
+ export function ResourceGrid({ label, items }: { label: string; items: ResourceTileItem[] }) {
20
+ return (
21
+ <TileGrid aria-label={label} empty={<p className="type-body text-muted">Nothing here yet.</p>}>
22
+ {items.map((item) => (
23
+ <TileGrid.Item key={item.id}>
24
+ <LinkTile
25
+ title={item.title}
26
+ href={item.href}
27
+ external={item.external}
28
+ meta={item.meta}
29
+ media={
30
+ <img
31
+ src={item.image.src}
32
+ alt={item.image.alt}
33
+ width={item.image.width}
34
+ height={item.image.height}
35
+ loading="lazy"
36
+ decoding="async"
37
+ />
38
+ }
39
+ />
40
+ </TileGrid.Item>
41
+ ))}
42
+ </TileGrid>
43
+ );
44
+ }
@@ -1,4 +1,4 @@
1
- // @thewhatmatters/wmds@0.4.7 · Pattern — stacked notifications
1
+ // @thewhatmatters/wmds@0.4.9 · Pattern — stacked notifications
2
2
  // Storybook: Components/Toast → Pattern — stacked notifications (?path=/story/components-toast--stacked-notifications)
3
3
  // Show code — copy verbatim and keep this header; upgrades find pasted patterns by it.
4
4
 
@@ -1,4 +1,4 @@
1
- // @thewhatmatters/wmds@0.4.7 · Pattern — controlled state
1
+ // @thewhatmatters/wmds@0.4.9 · Pattern — controlled state
2
2
  // Storybook: Components/Tooltip → Pattern — controlled state (?path=/story/components-tooltip--controlled)
3
3
  // Show code — copy verbatim and keep this header; upgrades find pasted patterns by it.
4
4
 
@@ -1,4 +1,4 @@
1
- // @thewhatmatters/wmds@0.4.7 · Pattern — disabled control
1
+ // @thewhatmatters/wmds@0.4.9 · Pattern — disabled control
2
2
  // Storybook: Components/Tooltip → Pattern — disabled control (?path=/story/components-tooltip--disabled-control)
3
3
  // Show code — copy verbatim and keep this header; upgrades find pasted patterns by it.
4
4
 
@@ -1,4 +1,4 @@
1
- // @thewhatmatters/wmds@0.4.7 · Pattern — icon-only action
1
+ // @thewhatmatters/wmds@0.4.9 · Pattern — icon-only action
2
2
  // Storybook: Components/Tooltip → Pattern — icon-only action (?path=/story/components-tooltip--icon-action)
3
3
  // Show code — copy verbatim and keep this header; upgrades find pasted patterns by it.
4
4
 
@@ -1,4 +1,4 @@
1
- // @thewhatmatters/wmds@0.4.7 · Pattern — keyboard shortcut
1
+ // @thewhatmatters/wmds@0.4.9 · Pattern — keyboard shortcut
2
2
  // Storybook: Components/Tooltip → Pattern — keyboard shortcut (?path=/story/components-tooltip--keyboard-shortcut)
3
3
  // Show code — copy verbatim and keep this header; upgrades find pasted patterns by it.
4
4
 
@@ -1,4 +1,4 @@
1
- // @thewhatmatters/wmds@0.4.7 · Pattern — placement
1
+ // @thewhatmatters/wmds@0.4.9 · Pattern — placement
2
2
  // Storybook: Components/Tooltip → Pattern — placement (?path=/story/components-tooltip--placement)
3
3
  // Show code — copy verbatim and keep this header; upgrades find pasted patterns by it.
4
4
 
@@ -1,4 +1,4 @@
1
- // @thewhatmatters/wmds@0.4.7 · Pattern — horizontal rail
1
+ // @thewhatmatters/wmds@0.4.9 · Pattern — horizontal rail
2
2
  // Storybook: Foundations/Scroll fade → Pattern — horizontal rail (?path=/story/foundations-scroll-fade--horizontal-rail)
3
3
  // Show code — copy verbatim and keep this header; upgrades find pasted patterns by it.
4
4
 
@@ -1,4 +1,4 @@
1
- // @thewhatmatters/wmds@0.4.7 · Pattern — overflowing list
1
+ // @thewhatmatters/wmds@0.4.9 · Pattern — overflowing list
2
2
  // Storybook: Foundations/Scroll fade → Pattern — overflowing list (?path=/story/foundations-scroll-fade--overflowing-list)
3
3
  // Show code — copy verbatim and keep this header; upgrades find pasted patterns by it.
4
4
 
@@ -1,4 +1,4 @@
1
- // @thewhatmatters/wmds@0.4.7 · Pattern — chart comparison gallery
1
+ // @thewhatmatters/wmds@0.4.9 · Pattern — chart comparison gallery
2
2
  // Storybook: Guides/Chart explorations → Pattern — chart comparison gallery (?path=/story/guides-chart-explorations--comparison-gallery)
3
3
  // Show code — copy verbatim and keep this header; upgrades find pasted patterns by it.
4
4
 
@@ -0,0 +1,182 @@
1
+ // @thewhatmatters/wmds@0.4.9 · Pattern — filtered grid
2
+ // Storybook: Guides/Filter panel → Pattern — filtered grid (?path=/story/guides-filter-panel--filtered-grid)
3
+ // Show code — copy verbatim and keep this header; upgrades find pasted patterns by it.
4
+
5
+ import { useId, type ReactElement, type ReactNode } from "react";
6
+ import {
7
+ Badge,
8
+ FilterPanel,
9
+ LinkTile,
10
+ SectionCaption,
11
+ TileGrid,
12
+ countFilterOptions,
13
+ matchesFilterSelection,
14
+ useFilterSelection,
15
+ type FilterFacets,
16
+ type FilterPanelGroup,
17
+ type FilterSelection,
18
+ type TileGridColumns,
19
+ type TileGridLayout,
20
+ } from "@thewhatmatters/wmds";
21
+
22
+ export interface ResourceImage {
23
+ src: string;
24
+ alt: string;
25
+ /** The image's own size, in px. They hold the tile's space before the image loads. */
26
+ width: number;
27
+ height: number;
28
+ }
29
+
30
+ export interface FilteredResource {
31
+ id: string;
32
+ title: string;
33
+ /** Where the tile goes — the resource's page in the app, for example /resources/type-scale. */
34
+ href: string;
35
+ /** Only for a tile that links straight to another site: it opens a new tab. */
36
+ external?: boolean;
37
+ /** The preview. */
38
+ image: ResourceImage;
39
+ /** One line under the title, for example the domain. */
40
+ meta?: string;
41
+ /** A short label over the image, for example "Free". */
42
+ tag?: string;
43
+ /** The resource's options in each filter group, by group id — one or several, for example { type: ["tools", "fonts"] }. */
44
+ facets: FilterFacets;
45
+ }
46
+
47
+ function resourcesLabel(count: number) {
48
+ return count === 1 ? "1 resource" : `${count} resources`;
49
+ }
50
+
51
+ /** The image's shape in a uniform grid, and of the placeholders while loading, as width / height. */
52
+ const uniformRatio = 4 / 3;
53
+
54
+ /** One placeholder tile each, while loading. */
55
+ const loadingTiles = ["one", "two", "three", "four", "five", "six"];
56
+
57
+ export interface FilteredGridProps {
58
+ title: string;
59
+ /** The caption over the grid, in sentence case. It names the list. */
60
+ caption: string;
61
+ items: FilteredResource[];
62
+ groups: FilterPanelGroup[];
63
+ /** masonry (default): tiles keep their image's height and pack. uniform: every image is 4:3. */
64
+ layout?: TileGridLayout;
65
+ /** Columns per breakpoint. Default: 1 on phones, 2 from sm, 3 from lg. */
66
+ columns?: TileGridColumns;
67
+ /** Placeholder tiles while the items are on their way. */
68
+ loading?: boolean;
69
+ /** The filters on when the page opens — for example { type: ["tools"] } from /resources?type=tools. */
70
+ defaultSelection?: FilterSelection;
71
+ /** The filters on, when the app holds them — for example in the URL. Pass with onSelectionChange. */
72
+ selection?: FilterSelection;
73
+ /** Every change: an option on or off, or Clear all. */
74
+ onSelectionChange?: (selection: FilterSelection) => void;
75
+ /** The router's link for a tile, in place of a plain anchor — (item) => <Link href={item.href} />. Not used for external items. */
76
+ renderLink?: (item: FilteredResource) => ReactElement;
77
+ /** The app's image component, in place of img — (image) => <Image {...image} sizes="…" />. */
78
+ renderImage?: (image: ResourceImage) => ReactNode;
79
+ /** Page-level layers inside the page grid — for example a GridOverlay. */
80
+ overlay?: ReactNode;
81
+ }
82
+
83
+ export function FilteredGrid({
84
+ title,
85
+ caption,
86
+ items,
87
+ groups,
88
+ layout = "masonry",
89
+ columns,
90
+ loading = false,
91
+ defaultSelection,
92
+ selection: selectionProp,
93
+ onSelectionChange,
94
+ renderLink,
95
+ renderImage,
96
+ overlay,
97
+ }: FilteredGridProps) {
98
+ const gridHeadingId = useId();
99
+ const [selection, setSelection] = useFilterSelection({
100
+ defaultSelection,
101
+ selection: selectionProp,
102
+ onSelectionChange,
103
+ });
104
+ const shown = items.filter((item) => matchesFilterSelection(item.facets, selection));
105
+
106
+ return (
107
+ <FilterPanel
108
+ groups={countFilterOptions(groups, items, resourcesLabel)}
109
+ selection={selection}
110
+ onSelectionChange={setSelection}
111
+ resultsLabel={resourcesLabel(shown.length)}
112
+ >
113
+ <main className="grid-page bg-body">
114
+ {overlay}
115
+ <div className="band py-6 sm:py-10 lg:py-14">
116
+ <header className="col-span-full mb-4 flex items-end justify-between gap-4 sm:mb-8">
117
+ <h1 className="type-display-2 text-fg">
118
+ {title} {loading ? null : <span className="tabular-nums text-muted">{shown.length}</span>}
119
+ </h1>
120
+ <FilterPanel.Trigger className="md:hidden" />
121
+ </header>
122
+ <p role="status" className="sr-only">
123
+ {loading ? "Loading" : resourcesLabel(shown.length)}
124
+ </p>
125
+
126
+ <FilterPanel.Rail className="hidden md:col-span-3 md:block" />
127
+
128
+ <div className="col-span-full md:col-span-5 lg:col-span-9">
129
+ <SectionCaption id={gridHeadingId}>{caption}</SectionCaption>
130
+ <TileGrid
131
+ aria-labelledby={gridHeadingId}
132
+ layout={layout}
133
+ columns={columns}
134
+ busy={loading}
135
+ empty={<p className="type-body text-muted">No resources match these filters.</p>}
136
+ className="mt-6"
137
+ >
138
+ {loading
139
+ ? loadingTiles.map((key) => (
140
+ <TileGrid.Item key={key}>
141
+ <LinkTile loading ratio={uniformRatio} />
142
+ </TileGrid.Item>
143
+ ))
144
+ : shown.map((item) => {
145
+ const routed = renderLink != null && !item.external;
146
+ return (
147
+ <TileGrid.Item key={item.id}>
148
+ <LinkTile
149
+ title={item.title}
150
+ href={routed ? undefined : item.href}
151
+ render={routed ? renderLink(item) : undefined}
152
+ external={item.external}
153
+ meta={item.meta}
154
+ ratio={layout === "uniform" ? uniformRatio : undefined}
155
+ tag={item.tag != null ? <Badge>{item.tag}</Badge> : undefined}
156
+ media={
157
+ renderImage != null ? (
158
+ renderImage(item.image)
159
+ ) : (
160
+ <img
161
+ src={item.image.src}
162
+ alt={item.image.alt}
163
+ width={item.image.width}
164
+ height={item.image.height}
165
+ loading="lazy"
166
+ decoding="async"
167
+ />
168
+ )
169
+ }
170
+ />
171
+ </TileGrid.Item>
172
+ );
173
+ })}
174
+ </TileGrid>
175
+ </div>
176
+ </div>
177
+
178
+ <FilterPanel.Sheet />
179
+ </main>
180
+ </FilterPanel>
181
+ );
182
+ }
@@ -1,16 +1,18 @@
1
- // @thewhatmatters/wmds@0.4.7 · Pattern — filtered index
1
+ // @thewhatmatters/wmds@0.4.9 · Pattern — filtered index
2
2
  // Storybook: Guides/Filter panel → Pattern — filtered index (?path=/story/guides-filter-panel--filtered-index)
3
3
  // Show code — copy verbatim and keep this header; upgrades find pasted patterns by it.
4
4
 
5
- import { useId, useState } from "react";
6
- import { SlidersHorizontal } from "lucide-react";
7
- import { Accordion, Button, CheckboxGroup, IndexList, SectionCaption, Sheet } from "@thewhatmatters/wmds";
8
-
9
- export interface FilterGroupDef {
10
- id: string;
11
- label: string;
12
- options: { value: string; label: string }[];
13
- }
5
+ import { type ReactElement, type ReactNode } from "react";
6
+ import {
7
+ FilterPanel,
8
+ IndexList,
9
+ countFilterOptions,
10
+ matchesFilterSelection,
11
+ useFilterSelection,
12
+ type FilterFacets,
13
+ type FilterPanelGroup,
14
+ type FilterSelection,
15
+ } from "@thewhatmatters/wmds";
14
16
 
15
17
  export interface FilteredPost {
16
18
  slug: string;
@@ -22,80 +24,27 @@ export interface FilteredPost {
22
24
  dateLabel: string;
23
25
  description: string;
24
26
  /** The post's options in each filter group, by group id — one or several, for example { topic: ["guides", "brand"] }. */
25
- facets: Record<string, string | string[]>;
27
+ facets: FilterFacets;
26
28
  }
27
29
 
28
- /** The options on in each group, by group id — for example { topic: ["guides"] }. */
29
- export type FilterSelection = Record<string, string[]>;
30
-
31
30
  function postsLabel(count: number) {
32
31
  return count === 1 ? "1 post" : `${count} posts`;
33
32
  }
34
33
 
35
- function facetValues(post: FilteredPost, groupId: string): string[] {
36
- const value = post.facets[groupId];
37
- return value == null ? [] : Array.isArray(value) ? value : [value];
38
- }
39
-
40
- /** A post shows when, in every group with options on, it has at least one of them. */
41
- function matches(post: FilteredPost, groups: FilterGroupDef[], selection: FilterSelection) {
42
- return groups.every((group) => {
43
- const values = selection[group.id] ?? [];
44
- return values.length === 0 || facetValues(post, group.id).some((value) => values.includes(value));
45
- });
46
- }
47
-
48
- function FilterGroups({
49
- groups,
50
- posts,
51
- selection,
52
- onGroupChange,
53
- }: {
54
- groups: FilterGroupDef[];
55
- posts: FilteredPost[];
56
- selection: FilterSelection;
57
- onGroupChange: (groupId: string, values: string[]) => void;
58
- }) {
59
- return (
60
- <Accordion variant="plain" flush>
61
- {groups.map((group) => (
62
- <Accordion.Item key={group.id} label={group.label} defaultOpen>
63
- <CheckboxGroup
64
- label={group.label}
65
- labelHidden
66
- size="sm"
67
- values={selection[group.id] ?? []}
68
- onValuesChange={(values) => onGroupChange(group.id, values)}
69
- >
70
- {group.options.map((option) => {
71
- const count = posts.filter((post) => facetValues(post, group.id).includes(option.value)).length;
72
- return (
73
- <CheckboxGroup.Item
74
- key={option.value}
75
- value={option.value}
76
- label={option.label}
77
- count={count}
78
- countLabel={postsLabel(count)}
79
- />
80
- );
81
- })}
82
- </CheckboxGroup>
83
- </Accordion.Item>
84
- ))}
85
- </Accordion>
86
- );
87
- }
88
-
89
34
  export interface FilteredIndexProps {
90
35
  title: string;
91
36
  posts: FilteredPost[];
92
- groups: FilterGroupDef[];
37
+ groups: FilterPanelGroup[];
93
38
  /** The filters on when the page opens — for example { topic: ["guides"] } from /blog?topic=guides. */
94
39
  defaultSelection?: FilterSelection;
95
40
  /** The filters on, when the app holds them — for example in the URL. Pass with onSelectionChange. */
96
41
  selection?: FilterSelection;
97
42
  /** Every change: an option on or off, or Clear all. */
98
43
  onSelectionChange?: (selection: FilterSelection) => void;
44
+ /** The router's link for a post's title, in place of a plain anchor — (post) => <Link href={post.href} />. */
45
+ renderLink?: (post: FilteredPost) => ReactElement;
46
+ /** Page-level layers inside the page grid — for example a GridOverlay. */
47
+ overlay?: ReactNode;
99
48
  }
100
49
 
101
50
  export function FilteredIndex({
@@ -105,94 +54,60 @@ export function FilteredIndex({
105
54
  defaultSelection,
106
55
  selection: selectionProp,
107
56
  onSelectionChange,
57
+ renderLink,
58
+ overlay,
108
59
  }: FilteredIndexProps) {
109
- const filtersHeadingId = useId();
110
- const [ownSelection, setOwnSelection] = useState<FilterSelection>(defaultSelection ?? {});
111
- const selection = selectionProp ?? ownSelection;
112
- const [sheetOpen, setSheetOpen] = useState(false);
113
- const activeCount = Object.values(selection).reduce((total, values) => total + values.length, 0);
114
- const shown = posts.filter((post) => matches(post, groups, selection));
115
-
116
- function changeSelection(next: FilterSelection) {
117
- if (selectionProp == null) setOwnSelection(next);
118
- onSelectionChange?.(next);
119
- }
120
-
121
- function setGroup(groupId: string, values: string[]) {
122
- changeSelection({ ...selection, [groupId]: values });
123
- }
60
+ const [selection, setSelection] = useFilterSelection({
61
+ defaultSelection,
62
+ selection: selectionProp,
63
+ onSelectionChange,
64
+ });
65
+ const shown = posts.filter((post) => matchesFilterSelection(post.facets, selection));
124
66
 
125
67
  return (
126
- <main className="grid-page bg-body">
127
- <div className="band py-6 sm:py-10 lg:py-14">
128
- <header className="col-span-full mb-4 flex items-end justify-between gap-4 sm:mb-8">
129
- <h1 className="type-display-2 text-fg">
130
- {title} <span className="tabular-nums text-muted">{shown.length}</span>
131
- </h1>
132
- <Button
133
- role="secondary"
134
- icon={<SlidersHorizontal />}
135
- count={activeCount > 0 ? activeCount : undefined}
136
- className="md:hidden"
137
- onClick={() => setSheetOpen(true)}
138
- >
139
- Filters
140
- </Button>
141
- </header>
142
- <p role="status" className="sr-only">
143
- {postsLabel(shown.length)}
144
- </p>
145
-
146
- <aside aria-labelledby={filtersHeadingId} className="hidden md:col-span-3 md:block">
147
- <SectionCaption
148
- id={filtersHeadingId}
149
- end={
150
- <Button role="ghost" size="xs" disabled={activeCount === 0} onClick={() => changeSelection({})}>
151
- Clear all
152
- </Button>
153
- }
154
- >
155
- Filters
156
- </SectionCaption>
157
- <FilterGroups groups={groups} posts={posts} selection={selection} onGroupChange={setGroup} />
158
- </aside>
159
-
160
- <div className="col-span-full md:col-span-5 lg:col-span-9">
161
- <IndexList
162
- aria-label="Posts"
163
- captions={{ meta: "Date", title: "Name" }}
164
- empty={<p className="type-body text-muted">No posts match these filters.</p>}
165
- >
166
- {shown.map((post) => (
167
- <IndexList.Item
168
- key={post.slug}
169
- title={post.title}
170
- href={post.href}
171
- meta={<time dateTime={post.date}>{post.dateLabel}</time>}
172
- preview={<p>{post.description}</p>}
173
- />
174
- ))}
175
- </IndexList>
68
+ <FilterPanel
69
+ groups={countFilterOptions(groups, posts, postsLabel)}
70
+ selection={selection}
71
+ onSelectionChange={setSelection}
72
+ resultsLabel={postsLabel(shown.length)}
73
+ >
74
+ <main className="grid-page bg-body">
75
+ {overlay}
76
+ <div className="band py-6 sm:py-10 lg:py-14">
77
+ <header className="col-span-full mb-4 flex items-end justify-between gap-4 sm:mb-8">
78
+ <h1 className="type-display-2 text-fg">
79
+ {title} <span className="tabular-nums text-muted">{shown.length}</span>
80
+ </h1>
81
+ <FilterPanel.Trigger className="md:hidden" />
82
+ </header>
83
+ <p role="status" className="sr-only">
84
+ {postsLabel(shown.length)}
85
+ </p>
86
+
87
+ <FilterPanel.Rail className="hidden md:col-span-3 md:block" />
88
+
89
+ <div className="col-span-full md:col-span-5 lg:col-span-9">
90
+ <IndexList
91
+ aria-label="Posts"
92
+ captions={{ meta: "Date", title: "Name" }}
93
+ empty={<p className="type-body text-muted">No posts match these filters.</p>}
94
+ >
95
+ {shown.map((post) => (
96
+ <IndexList.Item
97
+ key={post.slug}
98
+ title={post.title}
99
+ href={renderLink != null ? undefined : post.href}
100
+ render={renderLink?.(post)}
101
+ meta={<time dateTime={post.date}>{post.dateLabel}</time>}
102
+ preview={<p>{post.description}</p>}
103
+ />
104
+ ))}
105
+ </IndexList>
106
+ </div>
176
107
  </div>
177
- </div>
178
108
 
179
- <Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
180
- <Sheet.Content
181
- title="Filters"
182
- footer={
183
- <div className="flex items-center justify-end gap-2">
184
- <Button role="secondary" size="sm" disabled={activeCount === 0} onClick={() => changeSelection({})}>
185
- Clear all
186
- </Button>
187
- <Button role="primary" size="sm" onClick={() => setSheetOpen(false)}>
188
- Show {postsLabel(shown.length)}
189
- </Button>
190
- </div>
191
- }
192
- >
193
- <FilterGroups groups={groups} posts={posts} selection={selection} onGroupChange={setGroup} />
194
- </Sheet.Content>
195
- </Sheet>
196
- </main>
109
+ <FilterPanel.Sheet />
110
+ </main>
111
+ </FilterPanel>
197
112
  );
198
113
  }
@@ -1,4 +1,4 @@
1
- // @thewhatmatters/wmds@0.4.7 · Pattern — notification preferences
1
+ // @thewhatmatters/wmds@0.4.9 · Pattern — notification preferences
2
2
  // Storybook: Guides/Overlay flows → Pattern — notification preferences (?path=/story/guides-overlay-flows--notification-preferences)
3
3
  // Show code — copy verbatim and keep this header; upgrades find pasted patterns by it.
4
4