@pnx-mixtape/mxds 0.1.0-rc.3 → 0.1.0-rc.5
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/.claude/skills/create-mxds-component/SKILL.md +77 -0
- package/DESIGN.md +595 -0
- package/README.md +1 -2
- package/dist/build/accordion.css +1 -1
- package/dist/build/accordion.entry.js +2 -1
- package/dist/build/autocomplete.entry.js +1 -1
- package/dist/build/back-to-top.css +1 -0
- package/dist/build/back-to-top.entry.js +1 -0
- package/dist/build/base.css +1 -1
- package/dist/build/button.css +1 -1
- package/dist/build/card.css +1 -1
- package/dist/build/carousel.css +1 -1
- package/dist/build/chunks/Accordion-D2wp8hBG.js +44 -0
- package/dist/build/chunks/AccordionGroup-BYtoAFxg.js +47 -0
- package/dist/build/chunks/AccordionMobile-D3sTeiM5.js +26 -0
- package/dist/build/chunks/Autocomplete-kRo8RNbC.js +135 -0
- package/dist/build/chunks/BackToTop-4M-Wl7z3.js +64 -0
- package/dist/build/chunks/ClosableAlert-yy84gae6.js +55 -0
- package/dist/build/chunks/CookieConsent-wd8gQ2Oe.js +18 -0
- package/dist/build/chunks/Dialog-DWoXW-7Z.js +48 -0
- package/dist/build/chunks/DropMenu-CSTZq0Zt.js +68 -0
- package/dist/build/chunks/FeedbackForm-BochXEDx.js +64 -0
- package/dist/build/chunks/Gallery-Rkfv7Mbb.js +165 -0
- package/dist/build/chunks/GlobalToggle-CzlkpLpc.js +82 -0
- package/dist/build/chunks/InPageNavigation-D1Kk-rCm.js +86 -0
- package/dist/build/chunks/Navigation-CDQnN61h.js +147 -0
- package/dist/build/chunks/Popover-CiTAftNU.js +30 -0
- package/dist/build/chunks/Sticky-DoVndENm.js +51 -0
- package/dist/build/chunks/Tabs-BDlxCDNh.js +160 -0
- package/dist/build/chunks/Translate-bmM38j6z.js +46 -0
- package/dist/build/chunks/cookie-compliance-CG6x-TcV.js +119 -0
- package/dist/build/chunks/enums-BrHxPZZ_.js +36 -0
- package/dist/build/chunks/polyfills-D-qh1a_2.js +33 -0
- package/dist/build/chunks/utilities-Dd-08SW6.js +255 -0
- package/dist/build/constants.css +1 -1
- package/dist/build/content-block.css +1 -1
- package/dist/build/cookie-consent.css +1 -1
- package/dist/build/cookie-consent.entry.js +1 -1
- package/dist/build/dialog.css +1 -1
- package/dist/build/dialog.entry.js +1 -1
- package/dist/build/drop-menu.entry.js +1 -1
- package/dist/build/elements.js +147 -1
- package/dist/build/feedback-form.css +1 -0
- package/dist/build/feedback-form.entry.js +1 -0
- package/dist/build/filters.entry.js +145 -1
- package/dist/build/form.css +1 -1
- package/dist/build/gallery.entry.js +1 -1
- package/dist/build/global-alert.css +1 -1
- package/dist/build/global-alert.entry.js +1 -1
- package/dist/build/grid.css +1 -1
- package/dist/build/header.css +1 -1
- package/dist/build/header.entry.js +1 -1
- package/dist/build/icon.css +1 -1
- package/dist/build/in-page-navigation.entry.js +1 -1
- package/dist/build/navigation.css +1 -1
- package/dist/build/navigation.entry.js +1 -1
- package/dist/build/pagination.css +1 -1
- package/dist/build/popover.css +1 -1
- package/dist/build/popover.entry.js +1 -1
- package/dist/build/react.js +2270 -1
- package/dist/build/sticky.entry.js +1 -1
- package/dist/build/tabs.css +1 -1
- package/dist/build/tabs.entry.js +1 -1
- package/dist/build/translate.css +1 -0
- package/dist/build/translate.entry.js +1 -0
- package/dist/build/utility-list.entry.js +53 -1
- package/dist/types/Component/BackToTop/BackToTop.d.ts +20 -0
- package/dist/types/Component/BackToTop/BackToTop.d.ts.map +1 -0
- package/dist/types/Component/BackToTop/Elements/BackToTop.d.ts +27 -0
- package/dist/types/Component/BackToTop/Elements/BackToTop.d.ts.map +1 -0
- package/dist/types/Component/BackToTop/back-to-top.entry.d.ts +2 -0
- package/dist/types/Component/BackToTop/back-to-top.entry.d.ts.map +1 -0
- package/dist/types/Component/DropMenu/DropMenu.d.ts +2 -1
- package/dist/types/Component/DropMenu/DropMenu.d.ts.map +1 -1
- package/dist/types/Component/Tabs/Components/TabDropMenu.d.ts.map +1 -1
- package/dist/types/Component/Tabs/Elements/Tabs.d.ts.map +1 -1
- package/dist/types/Component/Translate/Elements/Translate.d.ts +23 -0
- package/dist/types/Component/Translate/Elements/Translate.d.ts.map +1 -0
- package/dist/types/Component/Translate/Translate.d.ts +37 -0
- package/dist/types/Component/Translate/Translate.d.ts.map +1 -0
- package/dist/types/Component/Translate/languages.d.ts +11 -0
- package/dist/types/Component/Translate/languages.d.ts.map +1 -0
- package/dist/types/Component/Translate/translate.entry.d.ts +2 -0
- package/dist/types/Component/Translate/translate.entry.d.ts.map +1 -0
- package/dist/types/Form/FeedbackForm/Elements/FeedbackForm.d.ts +37 -0
- package/dist/types/Form/FeedbackForm/Elements/FeedbackForm.d.ts.map +1 -0
- package/dist/types/Form/FeedbackForm/FeedbackForm.d.ts +36 -0
- package/dist/types/Form/FeedbackForm/FeedbackForm.d.ts.map +1 -0
- package/dist/types/Form/FeedbackForm/feedback-form.entry.d.ts +2 -0
- package/dist/types/Form/FeedbackForm/feedback-form.entry.d.ts.map +1 -0
- package/dist/types/Utility/Elements/google-translate-widget.d.ts +91 -0
- package/dist/types/Utility/Elements/google-translate-widget.d.ts.map +1 -0
- package/dist/types/Utility/utilities.d.ts +2 -0
- package/dist/types/Utility/utilities.d.ts.map +1 -1
- package/dist/types/elements.d.ts +3 -0
- package/dist/types/elements.d.ts.map +1 -1
- package/dist/types/enums.d.ts +3 -1
- package/dist/types/enums.d.ts.map +1 -1
- package/dist/types/react.d.ts +3 -0
- package/dist/types/react.d.ts.map +1 -1
- package/docs/API.md +1215 -0
- package/docs/CSSGuidelines.md +891 -0
- package/docs/CodeQuality.md +940 -0
- package/docs/ComponentArchitecture.md +714 -0
- package/docs/Contributing.md +626 -0
- package/docs/MemoryManagement.md +598 -0
- package/docs/Migrations.md +6 -0
- package/docs/README.md +100 -0
- package/docs/Security.md +444 -0
- package/docs/Testing.md +739 -0
- package/docs/Troubleshooting.md +674 -0
- package/package.json +6 -3
- package/src/Atom/Button/Button.tsx +1 -1
- package/src/Atom/Button/__snapshots__/Button.stories.tsx.snap +2 -2
- package/src/Atom/Button/_buttons.css +1 -0
- package/src/Atom/Icon/README.md +1 -1
- package/src/Atom/Icon/_icon.css +1 -0
- package/src/Atom/Icon/images/thumb.svg +1 -0
- package/src/Atom/Media/__snapshots__/Media.stories.ts.snap +4 -9
- package/src/Atom/Media/media.twig +4 -5
- package/src/Atom/_generic.css +0 -10
- package/src/Component/Accordion/accordion.css +3 -1
- package/src/Component/BackToTop/BackToTop.stories.ts +127 -0
- package/src/Component/BackToTop/BackToTop.stories.tsx +53 -0
- package/src/Component/BackToTop/BackToTop.tsx +120 -0
- package/src/Component/BackToTop/Elements/BackToTop.ts +119 -0
- package/src/Component/BackToTop/README.md +164 -0
- package/src/Component/BackToTop/__snapshots__/BackToTop.stories.ts.snap +24 -0
- package/src/Component/BackToTop/__snapshots__/BackToTop.stories.tsx.snap +3 -0
- package/src/Component/BackToTop/back-to-top.css +70 -0
- package/src/Component/BackToTop/back-to-top.entry.js +1 -0
- package/src/Component/BackToTop/back-to-top.twig +26 -0
- package/src/Component/Card/Components/CardContent.tsx +1 -1
- package/src/Component/Card/Components/CardMedia.tsx +1 -1
- package/src/Component/Card/__snapshots__/Card.stories.tsx.snap +3 -3
- package/src/Component/Card/card.css +1 -1
- package/src/Component/Carousel/Carousel.stories.ts +66 -36
- package/src/Component/Carousel/__snapshots__/Carousel.stories.tsx.snap +5 -5
- package/src/Component/Carousel/carousel.css +1 -1
- package/src/Component/ContentBlock/Components/ContentBlockMedia.tsx +1 -1
- package/src/Component/ContentBlock/content-block.css +1 -1
- package/src/Component/CookieConsent/cookie-consent.css +3 -0
- package/src/Component/Dialog/dialog.css +5 -2
- package/src/Component/DropMenu/DropMenu.stories.ts +21 -2
- package/src/Component/DropMenu/DropMenu.tsx +14 -4
- package/src/Component/DropMenu/__snapshots__/DropMenu.stories.ts.snap +3 -3
- package/src/Component/DropMenu/__snapshots__/DropMenu.stories.tsx.snap +2 -2
- package/src/Component/DropMenu/drop-menu.twig +7 -1
- package/src/Component/GlobalAlert/global-alert.css +3 -0
- package/src/Component/HeroBanner/__snapshots__/HeroBanner.stories.tsx.snap +1 -1
- package/src/Component/InPageNavigation/__snapshots__/InPageNavigation.stories.ts.snap +48 -13
- package/src/Component/InPageNavigation/in-page-navigation.twig +4 -6
- package/src/Component/ListItem/Components/ListItemMedia.tsx +1 -1
- package/src/Component/Navigation/_navigation-collapsible.css +3 -1
- package/src/Component/Navigation/_navigation-dropdown.css +3 -1
- package/src/Component/Pagination/Pagination.stories.ts +57 -27
- package/src/Component/Pagination/pagination.css +16 -7
- package/src/Component/Popover/__snapshots__/Popover.stories.tsx.snap +3 -3
- package/src/Component/Popover/popover.css +3 -1
- package/src/Component/Tabs/Components/TabDropMenu.tsx +7 -1
- package/src/Component/Tabs/Elements/Tabs.ts +13 -3
- package/src/Component/Tabs/Tabs.stories.ts +33 -12
- package/src/Component/Tabs/Tabs.stories.tsx +28 -14
- package/src/Component/Tabs/__snapshots__/Tabs.stories.ts.snap +3 -3
- package/src/Component/Tabs/__snapshots__/Tabs.stories.tsx.snap +2 -2
- package/src/Component/Tabs/tabs.css +4 -5
- package/src/Component/Translate/Elements/Translate.ts +76 -0
- package/src/Component/Translate/README.md +283 -0
- package/src/Component/Translate/Translate.stories.ts +87 -0
- package/src/Component/Translate/Translate.stories.tsx +77 -0
- package/src/Component/Translate/Translate.tsx +213 -0
- package/src/Component/Translate/__snapshots__/Translate.stories.ts.snap +18 -0
- package/src/Component/Translate/__snapshots__/Translate.stories.tsx.snap +3 -0
- package/src/Component/Translate/languages.ts +39 -0
- package/src/Component/Translate/translate.css +62 -0
- package/src/Component/Translate/translate.entry.js +1 -0
- package/src/Component/Translate/translate.twig +44 -0
- package/src/Form/Autocomplete/Autocomplete.stories.tsx +2 -1
- package/src/Form/FeedbackForm/Elements/FeedbackForm.ts +122 -0
- package/src/Form/FeedbackForm/FeedbackForm.stories.ts +99 -0
- package/src/Form/FeedbackForm/FeedbackForm.stories.tsx +73 -0
- package/src/Form/FeedbackForm/FeedbackForm.tsx +196 -0
- package/src/Form/FeedbackForm/README.md +233 -0
- package/src/Form/FeedbackForm/__snapshots__/FeedbackForm.stories.ts.snap +125 -0
- package/src/Form/FeedbackForm/__snapshots__/FeedbackForm.stories.tsx.snap +5 -0
- package/src/Form/FeedbackForm/feedback-form.css +74 -0
- package/src/Form/FeedbackForm/feedback-form.entry.js +1 -0
- package/src/Form/FeedbackForm/feedback-form.twig +80 -0
- package/src/Form/form.css +5 -3
- package/src/Layout/Grid/Grid.stories.ts +1 -0
- package/src/Layout/Grid/Grid.stories.tsx +26 -0
- package/src/Layout/Grid/__snapshots__/Grid.stories.tsx.snap +2 -0
- package/src/Layout/Grid/grid.css +12 -0
- package/src/Layout/Header/Components/GlobalToggle.tsx +1 -1
- package/src/Layout/Header/Header.stories.ts +53 -48
- package/src/Layout/Header/Header.stories.tsx +35 -22
- package/src/Layout/Header/Header.tsx +2 -2
- package/src/Layout/Header/__snapshots__/Header.stories.ts.snap +14 -10
- package/src/Layout/Header/__snapshots__/Header.stories.tsx.snap +2 -2
- package/src/Layout/Header/_header.css +4 -2
- package/src/Layout/Header/_toggles.css +9 -6
- package/src/Layout/Page/__snapshots__/Page.stories.tsx.snap +1 -1
- package/src/Utility/Elements/google-translate-widget.ts +293 -0
- package/src/Utility/_mixins.css +35 -13
- package/src/Utility/global.d.ts +4 -0
- package/src/Utility/utilities.ts +2 -0
- package/src/_custom-media.css +2 -2
- package/src/constants.css +3 -13
- package/src/elements.ts +3 -0
- package/src/enums.ts +2 -0
- package/src/react.ts +3 -0
- package/dist/build/chunks/Accordion-GN0sME4W.js +0 -1
- package/dist/build/chunks/AccordionGroup-Bzqp5__q.js +0 -1
- package/dist/build/chunks/AccordionMobile-DG5jSe8Q.js +0 -1
- package/dist/build/chunks/Autocomplete-B4RtkAyh.js +0 -1
- package/dist/build/chunks/ClosableAlert-DEhj8Nxm.js +0 -1
- package/dist/build/chunks/CookieConsent-BkCMRVST.js +0 -1
- package/dist/build/chunks/Dialog-Haahn-Yn.js +0 -1
- package/dist/build/chunks/DropMenu-1baQc8Yc.js +0 -1
- package/dist/build/chunks/Gallery-D8NGXy2Q.js +0 -1
- package/dist/build/chunks/GlobalToggle-B52fdBQu.js +0 -1
- package/dist/build/chunks/InPageNavigation-Dyn2TR-N.js +0 -1
- package/dist/build/chunks/Navigation-hOrfLq7h.js +0 -1
- package/dist/build/chunks/Popover-Cxg-s5Jn.js +0 -1
- package/dist/build/chunks/Sticky-BGCuuEJK.js +0 -1
- package/dist/build/chunks/Tabs-DYy1YTdU.js +0 -15
- package/dist/build/chunks/cookie-compliance-Bbm-0vpj.js +0 -1
- package/dist/build/chunks/enums-DLV_1NAf.js +0 -1
- package/dist/build/chunks/polyfills-C7pRt54M.js +0 -1
- package/dist/build/chunks/utilities-_Bq3TxAQ.js +0 -1
|
@@ -0,0 +1,714 @@
|
|
|
1
|
+
# Component Architecture
|
|
2
|
+
|
|
3
|
+
This guide explains how to structure and build components in the Mixtape Design System.
|
|
4
|
+
|
|
5
|
+
## Table of Contents
|
|
6
|
+
|
|
7
|
+
- [Component Types](#component-types)
|
|
8
|
+
- [Web Components](#web-components)
|
|
9
|
+
- [React Components](#react-components)
|
|
10
|
+
- [Twig Templates](#twig-templates)
|
|
11
|
+
- [Figma Code Connect](#figma-code-connect)
|
|
12
|
+
- [File Organization](#file-organization)
|
|
13
|
+
- [Props & Types](#props--types)
|
|
14
|
+
- [Common Patterns](#common-patterns)
|
|
15
|
+
- [Best Practices](#best-practices)
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## Component Types
|
|
20
|
+
|
|
21
|
+
The design system supports three component formats:
|
|
22
|
+
|
|
23
|
+
1. **Web Components** (Custom Elements) - For framework-agnostic JavaScript
|
|
24
|
+
2. **React Components** - For React applications
|
|
25
|
+
3. **Twig Templates** - For Drupal integration
|
|
26
|
+
|
|
27
|
+
Most components provide all three formats for maximum flexibility.
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## Web Components
|
|
32
|
+
|
|
33
|
+
### Structure Pattern
|
|
34
|
+
|
|
35
|
+
```typescript
|
|
36
|
+
/**
|
|
37
|
+
* ComponentName
|
|
38
|
+
* @file Brief description of what the component does
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import { makeAnchor } from "../../../Utility/utilities"
|
|
42
|
+
|
|
43
|
+
export default class ComponentName extends HTMLElement {
|
|
44
|
+
// 1. Properties
|
|
45
|
+
internals_: ElementInternals
|
|
46
|
+
controller: AbortController
|
|
47
|
+
// ... other properties
|
|
48
|
+
|
|
49
|
+
// 2. Constructor
|
|
50
|
+
constructor() {
|
|
51
|
+
super()
|
|
52
|
+
this.internals_ = this.attachInternals()
|
|
53
|
+
this.controller = new AbortController()
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// 3. Lifecycle Methods
|
|
57
|
+
connectedCallback(): void {
|
|
58
|
+
if (!this.trigger || !this.target) return
|
|
59
|
+
|
|
60
|
+
const { signal } = this.controller
|
|
61
|
+
// Setup event listeners with signal
|
|
62
|
+
this.trigger.addEventListener("click", this.handleClick, { signal })
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
disconnectedCallback(): void {
|
|
66
|
+
// Cleanup - ALWAYS implement this
|
|
67
|
+
this.controller.abort()
|
|
68
|
+
this.observer?.disconnect()
|
|
69
|
+
clearTimeout(this.timeout)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// 4. Event Handlers (use arrow functions)
|
|
73
|
+
handleClick = (): void => {
|
|
74
|
+
// Handler logic
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// 5. Getters (with validation)
|
|
78
|
+
get trigger(): HTMLButtonElement {
|
|
79
|
+
const trigger = this.querySelector("button")
|
|
80
|
+
if (!trigger) {
|
|
81
|
+
throw new Error(`${this.localName} must contain a <button>`)
|
|
82
|
+
}
|
|
83
|
+
return trigger
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// 6. Helper Methods
|
|
87
|
+
private generatedId(): string {
|
|
88
|
+
return makeAnchor(this.textContent || "component")
|
|
89
|
+
}
|
|
90
|
+
// generatedId() wraps makeAnchor() to produce a stable HTML-safe ID.
|
|
91
|
+
// See API.md → generatedId() for details.
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// 7. Register the component
|
|
95
|
+
if (!customElements.get("mx-component")) {
|
|
96
|
+
customElements.define("mx-component", ComponentName)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// 8. TypeScript declaration
|
|
100
|
+
declare global {
|
|
101
|
+
interface HTMLElementTagNameMap {
|
|
102
|
+
"mx-component": ComponentName
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### Key Requirements
|
|
108
|
+
|
|
109
|
+
1. **Always extend HTMLElement**
|
|
110
|
+
2. **Use ElementInternals** for form-associated components
|
|
111
|
+
3. **Use AbortController** for event listener cleanup
|
|
112
|
+
4. **Store observers** as properties (iO, rO, mO)
|
|
113
|
+
5. **Implement disconnectedCallback()** for cleanup
|
|
114
|
+
6. **Validate DOM queries** in getters with error messages
|
|
115
|
+
7. **Use arrow functions** for event handlers (preserves `this`)
|
|
116
|
+
8. **Register with custom name** (mx-prefix)
|
|
117
|
+
9. **Add TypeScript declarations**
|
|
118
|
+
|
|
119
|
+
### Example: Accordion Component
|
|
120
|
+
|
|
121
|
+
```typescript
|
|
122
|
+
export default class Accordion extends HTMLElement {
|
|
123
|
+
internals_: ElementInternals
|
|
124
|
+
controller: AbortController
|
|
125
|
+
|
|
126
|
+
constructor() {
|
|
127
|
+
super()
|
|
128
|
+
this.internals_ = this.attachInternals()
|
|
129
|
+
this.controller = new AbortController()
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
connectedCallback(): void {
|
|
133
|
+
if (!this.details || !this.trigger) return
|
|
134
|
+
|
|
135
|
+
const { signal } = this.controller
|
|
136
|
+
document.addEventListener("beforeprint", this.handleOpen, { signal })
|
|
137
|
+
document.addEventListener("afterprint", this.handleClose, { signal })
|
|
138
|
+
this.handleHash()
|
|
139
|
+
document.addEventListener("hashchange", this.handleHash, { signal })
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
disconnectedCallback(): void {
|
|
143
|
+
this.controller.abort()
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
handleOpen = (): void => {
|
|
147
|
+
if (!this.details) return
|
|
148
|
+
this.details.open = true
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
handleClose = (): void => {
|
|
152
|
+
if (!this.details) return
|
|
153
|
+
this.details.open = false
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
handleHash = (): void => {
|
|
157
|
+
const { hash } = window.location
|
|
158
|
+
if (hash && hash === `#${this.details?.id}`) {
|
|
159
|
+
this.handleOpen()
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
get details(): HTMLDetailsElement | null {
|
|
164
|
+
const details = this.querySelector("details")
|
|
165
|
+
if (!details) {
|
|
166
|
+
throw new Error(`${this.localName} must contain a <details> element.`)
|
|
167
|
+
}
|
|
168
|
+
details.id = details.id || this.generatedId()
|
|
169
|
+
return details
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
get trigger(): HTMLElement | null {
|
|
173
|
+
return this.details?.querySelector("summary")
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
private generatedId(): string {
|
|
177
|
+
const text = this.trigger?.textContent || this.textContent || "accordion"
|
|
178
|
+
return makeAnchor(text)
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
---
|
|
184
|
+
|
|
185
|
+
## React Components
|
|
186
|
+
|
|
187
|
+
### Structure Pattern
|
|
188
|
+
|
|
189
|
+
```typescript
|
|
190
|
+
import { JSX, PropsWithChildren } from "react"
|
|
191
|
+
import classNames from "classnames"
|
|
192
|
+
import { AsTypes } from "@pnx-mixtape/ids-shape"
|
|
193
|
+
import { BackgroundStyles } from "../../enums"
|
|
194
|
+
|
|
195
|
+
// 1. Type Definition
|
|
196
|
+
type ComponentProps = PropsWithChildren & {
|
|
197
|
+
// Optional props with defaults
|
|
198
|
+
reversed?: boolean
|
|
199
|
+
block?: boolean
|
|
200
|
+
// Props with type constraints
|
|
201
|
+
as?: AsTypes
|
|
202
|
+
background?: BackgroundStyles
|
|
203
|
+
// Optional string props
|
|
204
|
+
className?: string
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// 2. Component with destructured props
|
|
208
|
+
const Component = ({
|
|
209
|
+
children,
|
|
210
|
+
reversed = false, // Default values
|
|
211
|
+
block = false,
|
|
212
|
+
as = AsTypes.DIV,
|
|
213
|
+
background = null,
|
|
214
|
+
className = null,
|
|
215
|
+
}: ComponentProps): JSX.Element => {
|
|
216
|
+
// 3. Dynamic element type
|
|
217
|
+
const Tag: JSX.ElementType = `${as}` as unknown as JSX.ElementType
|
|
218
|
+
|
|
219
|
+
return (
|
|
220
|
+
<Tag className="mx-container">
|
|
221
|
+
<article
|
|
222
|
+
className={classNames("mx-component", className, {
|
|
223
|
+
"mx-component--reversed": reversed,
|
|
224
|
+
"mx-component--block": block,
|
|
225
|
+
[`mx-background--${background}`]: background,
|
|
226
|
+
})}
|
|
227
|
+
>
|
|
228
|
+
{children}
|
|
229
|
+
</article>
|
|
230
|
+
</Tag>
|
|
231
|
+
)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export default Component
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
### Key Requirements
|
|
238
|
+
|
|
239
|
+
1. **Use TypeScript types** for all props
|
|
240
|
+
2. **Extend PropsWithChildren** if accepting children
|
|
241
|
+
3. **Provide default values** in destructuring
|
|
242
|
+
4. **Use classNames library** for conditional classes
|
|
243
|
+
5. **Return JSX.Element** type explicitly
|
|
244
|
+
6. **Export as default**
|
|
245
|
+
7. **Use semantic HTML** elements
|
|
246
|
+
|
|
247
|
+
### Example: Card Component
|
|
248
|
+
|
|
249
|
+
```typescript
|
|
250
|
+
import { JSX, PropsWithChildren } from "react"
|
|
251
|
+
import classNames from "classnames"
|
|
252
|
+
import { BackgroundStyles } from "../../enums"
|
|
253
|
+
import { AsTypes } from "@pnx-mixtape/ids-shape"
|
|
254
|
+
|
|
255
|
+
type CardProps = PropsWithChildren & {
|
|
256
|
+
reversed?: boolean
|
|
257
|
+
block?: boolean
|
|
258
|
+
as?: AsTypes
|
|
259
|
+
background?: BackgroundStyles
|
|
260
|
+
className?: string
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const Card = ({
|
|
264
|
+
children,
|
|
265
|
+
reversed = null,
|
|
266
|
+
block = false,
|
|
267
|
+
as = AsTypes.DIV,
|
|
268
|
+
background = null,
|
|
269
|
+
className = null,
|
|
270
|
+
}: CardProps): JSX.Element => {
|
|
271
|
+
const Tag: JSX.ElementType = `${as}` as unknown as JSX.ElementType
|
|
272
|
+
|
|
273
|
+
return (
|
|
274
|
+
<Tag className="mx-container">
|
|
275
|
+
<article
|
|
276
|
+
className={classNames("mx-card", className, {
|
|
277
|
+
"mx-card--reversed": reversed,
|
|
278
|
+
"mx-card--block": block,
|
|
279
|
+
[`mx-background--${background}`]: background,
|
|
280
|
+
})}
|
|
281
|
+
>
|
|
282
|
+
{children}
|
|
283
|
+
</article>
|
|
284
|
+
</Tag>
|
|
285
|
+
)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export default Card
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
---
|
|
292
|
+
|
|
293
|
+
## Twig Templates
|
|
294
|
+
|
|
295
|
+
### Structure Pattern
|
|
296
|
+
|
|
297
|
+
```twig
|
|
298
|
+
{% set baseClass = 'mx-component' %}
|
|
299
|
+
{% set classes = [baseClass] %}
|
|
300
|
+
{% set attributes = (attributes ?? create_attribute()).addClass(classes) %}
|
|
301
|
+
|
|
302
|
+
<div{{ attributes }}>
|
|
303
|
+
{{ content }}
|
|
304
|
+
</div>
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
### Key Requirements
|
|
308
|
+
|
|
309
|
+
1. **Use `create_attribute()`** for the root element's attributes, merging any passed-in `attributes`
|
|
310
|
+
2. **Prefer semantic HTML** elements, matching the equivalent React/Web Component markup
|
|
311
|
+
3. **Be selective with `{% for %}` loops** — see below, this is the most common way a Twig template breaks Drupal Canvas compatibility
|
|
312
|
+
|
|
313
|
+
### Avoid `{% for %}` loops over repeatable content
|
|
314
|
+
|
|
315
|
+
These Twig templates are the source that gets wrapped as Drupal Canvas (Experience Builder) components. Canvas's slot model hands a template **one pre-rendered value per slot**, never an array to iterate — an editor drops multiple blocks into a region, and Drupal concatenates their rendered output before the template ever sees it. A `{% for item in items %}` loop assumes the opposite (an array prop it can iterate), which breaks under Canvas: printing an array directly (`{{ items }}` without joining first) renders as the literal word `Array` in real Drupal/PHP Twig, or inserts stray commas in the Twig.js engine Storybook uses.
|
|
316
|
+
|
|
317
|
+
- **Never accept an array prop for repeatable content.** Take a single pre-joined string instead, and print it directly — `{{ items }}`, no `for`, no `join`, no `raw`. Build that string at the call site: `.stories.ts` files join an array of already-rendered items before passing them as an arg, e.g. `items: [AccordionItem({...}), AccordionItem({...})].join("")`.
|
|
318
|
+
- **If each item needs its own wrapper element** (e.g. `<li>`, a slide container), give that wrapper its own sibling sub-component rather than generating it from a loop in the parent. See `accordion-item.twig`, `tab-item.twig`, and `tag-item.twig`: the parent template is just `{{ items }}`, and each item component owns its complete root element, wrapper included.
|
|
319
|
+
- **`{% for %}` loops over a scalar array are fine** — e.g. building CSS modifier classes from a `modifiers: string[]` prop. This rule is about repeatable *content* (things an editor would add/remove/reorder as blocks), not plain data.
|
|
320
|
+
- **Exceptions**: native/technical markup that isn't a Canvas-editable "list" — `<option>` in a `<select>`, `<source>` in a `<picture>` — can keep a loop; these aren't things an editor rearranges as blocks.
|
|
321
|
+
|
|
322
|
+
```twig
|
|
323
|
+
{# accordion.twig — parent, no loop #}
|
|
324
|
+
<div class="mx-accordions">
|
|
325
|
+
{{ items }}
|
|
326
|
+
</div>
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
```twig
|
|
330
|
+
{# accordion-item.twig — owns its own <details> root, including any wrapper #}
|
|
331
|
+
<mx-accordion>
|
|
332
|
+
<details{{ attributes }}>
|
|
333
|
+
<summary class="mx-accordion__toggle">{{ title }}</summary>
|
|
334
|
+
<div class="mx-accordion__content">{{ content }}</div>
|
|
335
|
+
</details>
|
|
336
|
+
</mx-accordion>
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
---
|
|
340
|
+
|
|
341
|
+
## Figma Code Connect
|
|
342
|
+
|
|
343
|
+
Every component with a React implementation has a matching `Component.figma.ts` — a [Figma Code Connect](https://www.figma.com/code-connect-docs/) mapping that lets designers copy real component code straight from the Figma frame. It maps the React component only (`source` always points at the `.tsx` file); Twig/Web-Component variants aren't represented in Figma.
|
|
344
|
+
|
|
345
|
+
### Structure Pattern
|
|
346
|
+
|
|
347
|
+
```typescript
|
|
348
|
+
// url=https://www.figma.com/design/aGH90DVE6lrFVdLfDCHnFK/mxds---Design-System?node-id=65-234
|
|
349
|
+
// source=src/Component/Tag/Tag.tsx
|
|
350
|
+
// component=Tag
|
|
351
|
+
import figma from "figma"
|
|
352
|
+
|
|
353
|
+
const instance = figma.selectedInstance
|
|
354
|
+
|
|
355
|
+
const dismissible = instance.getEnum("Dismissible", { Off: false, On: true })
|
|
356
|
+
|
|
357
|
+
// Real children — a proper Content SLOT (not a fixed text layer)
|
|
358
|
+
const content = instance.getSlot("Content")
|
|
359
|
+
|
|
360
|
+
export default {
|
|
361
|
+
example: figma.code`<Tag>${content}</Tag>`,
|
|
362
|
+
imports: ['import { Tag } from "@pnx-mixtape/mxds/react"'],
|
|
363
|
+
id: "tag",
|
|
364
|
+
metadata: {
|
|
365
|
+
nestable: true,
|
|
366
|
+
},
|
|
367
|
+
}
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
- **Header comments** (`url`/`source`/`component`) are read by the Figma Code Connect CLI — `url` must point at the real frame's node, not a placeholder.
|
|
371
|
+
- **`figma.selectedInstance`** reads the actual Figma layer's variant properties (`getEnum`, `getBoolean`, `getSlot`, `getInstanceSwap`, …) so the exported snippet reflects whatever variant a designer has selected.
|
|
372
|
+
- **A slot must be interpolated directly inside the `figma.code` tagged template**, never assigned through an untagged intermediate string first — doing so passes `tsc` but silently renders as `[object Object]` in the real published snippet.
|
|
373
|
+
- **`id`** is the kebab-case component id used by the Figma library; `metadata.nestable` marks whether the component can be nested inside another mapped component's slot.
|
|
374
|
+
|
|
375
|
+
`tools/scaffold-component.mjs` generates a **shell only** — `url=TODO`, no real `getEnum`/`getSlot` calls — since those depend on the actual Figma frame. Populate it for real using the `code-to-figma-code-connect` skill rather than guessing the shape; it also covers validating (`figma connect parse`) and publishing.
|
|
376
|
+
|
|
377
|
+
---
|
|
378
|
+
|
|
379
|
+
## File Organization
|
|
380
|
+
|
|
381
|
+
### Component Directory Structure
|
|
382
|
+
|
|
383
|
+
```
|
|
384
|
+
Component/
|
|
385
|
+
├── README.md # Component documentation
|
|
386
|
+
├── Component.tsx # React component
|
|
387
|
+
├── Component.stories.tsx # React Storybook stories
|
|
388
|
+
├── Component.figma.ts # Figma Code Connect mapping (React only)
|
|
389
|
+
├── component.twig # Twig template
|
|
390
|
+
├── Component.stories.ts # Twig Storybook stories
|
|
391
|
+
├── component.css # Public CSS API
|
|
392
|
+
├── _component-partial.css # Internal CSS
|
|
393
|
+
├── Elements/ # Web Components (if needed)
|
|
394
|
+
│ └── ComponentElement.ts
|
|
395
|
+
├── Components/ # Sub-components (if needed)
|
|
396
|
+
│ └── ComponentPart.tsx
|
|
397
|
+
└── __snapshots__/ # Visual regression tests
|
|
398
|
+
├── Component.stories.ts.snap
|
|
399
|
+
└── Component.stories.tsx.snap
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
### File Naming Conventions
|
|
403
|
+
|
|
404
|
+
- **React**: `PascalCase.tsx` (Component.tsx)
|
|
405
|
+
- **Web Components**: `PascalCase.ts` (Accordion.ts)
|
|
406
|
+
- **Twig**: `kebab-case.twig` (accordion.twig)
|
|
407
|
+
- **CSS**: `kebab-case.css` (accordion.css)
|
|
408
|
+
- **Partials**: `_kebab-case.css` (_accordion-base.css)
|
|
409
|
+
- **Stories**: Match component name + `.stories.ts/tsx`
|
|
410
|
+
- **Figma Code Connect**: `PascalCase.figma.ts` (Component.figma.ts) — only for components with a React implementation
|
|
411
|
+
|
|
412
|
+
---
|
|
413
|
+
|
|
414
|
+
## Props & Types
|
|
415
|
+
|
|
416
|
+
### Type Definitions
|
|
417
|
+
|
|
418
|
+
```typescript
|
|
419
|
+
import { PropsWithChildren } from "react"
|
|
420
|
+
import { AsTypes } from "@pnx-mixtape/ids-shape"
|
|
421
|
+
|
|
422
|
+
// Simple props
|
|
423
|
+
type SimpleProps = {
|
|
424
|
+
title: string
|
|
425
|
+
active: boolean
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// With children
|
|
429
|
+
type WithChildrenProps = PropsWithChildren & {
|
|
430
|
+
className?: string
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// With element type
|
|
434
|
+
type FlexibleProps = PropsWithChildren & {
|
|
435
|
+
as?: AsTypes
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// With union types
|
|
439
|
+
type VariantProps = {
|
|
440
|
+
variant: "primary" | "secondary" | "tertiary"
|
|
441
|
+
size: "small" | "medium" | "large"
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// With optional complex types
|
|
445
|
+
type ComplexProps = PropsWithChildren & {
|
|
446
|
+
background?: BackgroundStyles
|
|
447
|
+
modifiers?: ButtonModifiers[]
|
|
448
|
+
onClick?: (event: MouseEvent) => void
|
|
449
|
+
}
|
|
450
|
+
```
|
|
451
|
+
|
|
452
|
+
### Default Values
|
|
453
|
+
|
|
454
|
+
```typescript
|
|
455
|
+
// ✅ Good - Defaults in destructuring
|
|
456
|
+
const Component = ({
|
|
457
|
+
variant = "primary",
|
|
458
|
+
size = "medium",
|
|
459
|
+
active = false,
|
|
460
|
+
}: Props) => {
|
|
461
|
+
// ...
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// ❌ Avoid - Defaults in type definition (TypeScript only, not runtime)
|
|
465
|
+
type Props = {
|
|
466
|
+
variant?: "primary" | "secondary" // No default here
|
|
467
|
+
}
|
|
468
|
+
```
|
|
469
|
+
|
|
470
|
+
---
|
|
471
|
+
|
|
472
|
+
## Common Patterns
|
|
473
|
+
|
|
474
|
+
### Disclosure Pattern
|
|
475
|
+
|
|
476
|
+
Used in: AccordionDiv, GlobalToggle, Navigation submenus (web component); Accordion, Header, Navigation DropdownLevel (React)
|
|
477
|
+
|
|
478
|
+
**Web component** — `DisclosureWidget` handles the toggle/attribute bookkeeping:
|
|
479
|
+
|
|
480
|
+
```typescript
|
|
481
|
+
import { DisclosureWidget } from "../../../Utility/utilities"
|
|
482
|
+
|
|
483
|
+
connectedCallback(): void {
|
|
484
|
+
this.widget = new DisclosureWidget(this.trigger, this.target)
|
|
485
|
+
this.widget.init()
|
|
486
|
+
|
|
487
|
+
const { signal } = this.controller
|
|
488
|
+
this.trigger.addEventListener('disclosure-toggle', this.handleToggle, { signal })
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
handleToggle = ({ detail }: CustomEvent<DisclosureWidgetEvent>): void => {
|
|
492
|
+
const { isOpen } = detail
|
|
493
|
+
// Handle state change
|
|
494
|
+
}
|
|
495
|
+
```
|
|
496
|
+
|
|
497
|
+
**React** — `useDisclosureState` (react-stately) tracks the boolean; we hand-write the button/panel markup and `aria-expanded`/`inert` wiring ourselves rather than using the full `useDisclosure` hook (see [React Aria / React Stately](#react-aria--react-stately) below for why):
|
|
498
|
+
|
|
499
|
+
```typescript
|
|
500
|
+
import { useDisclosureState } from "react-stately"
|
|
501
|
+
|
|
502
|
+
const { isExpanded, toggle } = useDisclosureState({ defaultExpanded: false })
|
|
503
|
+
|
|
504
|
+
return (
|
|
505
|
+
<>
|
|
506
|
+
<button aria-expanded={isExpanded} onClick={() => toggle()}>...</button>
|
|
507
|
+
<div inert={!isExpanded}>...</div>
|
|
508
|
+
</>
|
|
509
|
+
)
|
|
510
|
+
```
|
|
511
|
+
|
|
512
|
+
### Keyboard Navigation Pattern
|
|
513
|
+
|
|
514
|
+
Used in: Navigation flyout menus (`Keyboard` utility), DropMenu (`moveFocus`) — both web component; DropMenu, Tabs (React)
|
|
515
|
+
|
|
516
|
+
**Web component** — the shared `Keyboard` utility handles roving focus between links. `Navigation/Elements/Navigation.ts` is currently its only consumer — don't reach for it in new web components without a specific reason, prefer a small dedicated implementation instead. `DropMenu/Elements/DropMenu.ts`'s `moveFocus` is an example of that smaller alternative: a few lines of index math scoped to its own item list, rather than a shared class:
|
|
517
|
+
|
|
518
|
+
```typescript
|
|
519
|
+
import { Keyboard } from "../../../Utility/utilities"
|
|
520
|
+
|
|
521
|
+
const keyboard = new Keyboard()
|
|
522
|
+
|
|
523
|
+
useEffect(() => {
|
|
524
|
+
if (!containerRef.current || !open) return
|
|
525
|
+
|
|
526
|
+
const items = containerRef.current.querySelectorAll("button")
|
|
527
|
+
keyboard.attachMenu(containerRef.current, items)
|
|
528
|
+
|
|
529
|
+
const controller = new AbortController()
|
|
530
|
+
keyboard.attachEventListeners(controller.signal)
|
|
531
|
+
|
|
532
|
+
return () => {
|
|
533
|
+
controller.abort()
|
|
534
|
+
keyboard.detachMenu()
|
|
535
|
+
}
|
|
536
|
+
}, [open])
|
|
537
|
+
```
|
|
538
|
+
|
|
539
|
+
**React** — wrap the container in a `FocusScope`, then call `useFocusManager()` from each item to move focus in response to arrow keys:
|
|
540
|
+
|
|
541
|
+
```typescript
|
|
542
|
+
import { FocusScope, useFocusManager } from "react-aria"
|
|
543
|
+
|
|
544
|
+
// Container:
|
|
545
|
+
<FocusScope>{children}</FocusScope>
|
|
546
|
+
|
|
547
|
+
// Each item:
|
|
548
|
+
const focusManager = useFocusManager()
|
|
549
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
550
|
+
switch (event.key) {
|
|
551
|
+
case "ArrowDown":
|
|
552
|
+
focusManager?.focusNext({ wrap: true, tabbable: true })
|
|
553
|
+
break
|
|
554
|
+
case "ArrowUp":
|
|
555
|
+
focusManager?.focusPrevious({ wrap: true, tabbable: true })
|
|
556
|
+
break
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
```
|
|
560
|
+
|
|
561
|
+
> `tabbable: true` matters if any item can have `tabindex="-1"` (e.g. hidden/disabled) — the default "focusable" mode would otherwise still stop on them. Omit it for roving-tabindex widgets like Tabs, where non-active items are `tabindex="-1"` by design and *should* still be reachable by arrow keys.
|
|
562
|
+
|
|
563
|
+
### React Aria / React Stately
|
|
564
|
+
|
|
565
|
+
Before reaching for a react-aria/react-stately hook, check whether it does more than you asked for. Several of the higher-level "behavior" hooks assume a floating-overlay UI and carry side effects that don't fit this design system's Twig-parity, own-your-markup model:
|
|
566
|
+
|
|
567
|
+
- **Prefer the *state* hook (react-stately) over the matching *behavior* hook (react-aria) when the behavior hook assumes conventions we don't use.** `useDisclosureState` just tracks a boolean; the full `useDisclosure` hook imperatively toggles `hidden="until-found"` and injects `--disclosure-panel-width`/`--disclosure-panel-height` inline styles for its own animation model — which fights our CSS-driven `[data-open]`/`inert` collapse. `useComboBoxState` builds the collection/selection bookkeeping; `useComboBox` additionally calls `ariaHideOutside()` whenever open, `aria-hide`-ing the *entire rest of the page* — correct for a floating popover, wrong for an inline listbox like `Autocomplete`'s.
|
|
568
|
+
- **Check what a hook's returned methods actually alias to before using them** — a method's name doesn't always match its documented behavior. `useComboBoxState`'s `close` is aliased to `commitValue`, which resets the input to whatever the internal selection-key system thinks is "selected" — surprising if you handle selection yourself and never populate that system (as `Autocomplete` does). Read the hook's source, not just its `.d.ts`, when something behaves unexpectedly.
|
|
569
|
+
- **Verify empirically, not just by types.** Several of the surprises above only show up in rendered DOM or test output, not the type signatures — run the component's play-function tests (or open it in Storybook) after adopting a new hook.
|
|
570
|
+
|
|
571
|
+
### Observer Pattern
|
|
572
|
+
|
|
573
|
+
Used in: Carousel, Sticky, IntersectionLoader
|
|
574
|
+
|
|
575
|
+
```typescript
|
|
576
|
+
connectedCallback(): void {
|
|
577
|
+
// IntersectionObserver
|
|
578
|
+
this.iO = new IntersectionObserver(entries => {
|
|
579
|
+
entries.forEach(({ isIntersecting }) => {
|
|
580
|
+
if (isIntersecting) this.load()
|
|
581
|
+
})
|
|
582
|
+
})
|
|
583
|
+
this.iO.observe(this.element)
|
|
584
|
+
|
|
585
|
+
// ResizeObserver
|
|
586
|
+
this.rO = new ResizeObserver(entries => {
|
|
587
|
+
entries.forEach(() => this.handleResize())
|
|
588
|
+
})
|
|
589
|
+
this.rO.observe(this.element)
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
disconnectedCallback(): void {
|
|
593
|
+
this.iO?.disconnect()
|
|
594
|
+
this.rO?.disconnect()
|
|
595
|
+
}
|
|
596
|
+
```
|
|
597
|
+
|
|
598
|
+
### Lazy Loading Pattern
|
|
599
|
+
|
|
600
|
+
Used in: IntersectionLoader, BreakpointLoader
|
|
601
|
+
|
|
602
|
+
```typescript
|
|
603
|
+
import { IntersectionLoader } from "../../../Utility/utilities"
|
|
604
|
+
|
|
605
|
+
// Load component on intersection
|
|
606
|
+
IntersectionLoader.create("[data-lazy]", async () => {
|
|
607
|
+
const { default: Component } = await import("./Component")
|
|
608
|
+
// Initialize component
|
|
609
|
+
})
|
|
610
|
+
|
|
611
|
+
// Load component at breakpoint
|
|
612
|
+
import { BreakpointLoader } from "../../../Utility/utilities"
|
|
613
|
+
|
|
614
|
+
BreakpointLoader.create(
|
|
615
|
+
"[data-responsive]",
|
|
616
|
+
async () => {
|
|
617
|
+
const { default: Component } = await import("./Component")
|
|
618
|
+
// Initialize component
|
|
619
|
+
},
|
|
620
|
+
{ mediaQuery: "(min-width: 768px)" },
|
|
621
|
+
)
|
|
622
|
+
```
|
|
623
|
+
|
|
624
|
+
### Effect Timing Pattern (`useEffect` vs `useLayoutEffect`)
|
|
625
|
+
|
|
626
|
+
Used in: Filters (right-variant resize-reopen), Tabs (URL `?tab=` sync), Accordion (URL `#hash` deep-link), PaginationContext (URL `?page=` sync on load), Dialog + Gallery (imperative `showModal()`/`close()` + scroll-to-slide), Autocomplete (scroll the active option into view) — all React.
|
|
627
|
+
|
|
628
|
+
If a `useEffect` sets state (or calls an imperative DOM API) whose result is part of the *same render's* visible output, the browser can paint the stale value for one frame before the effect corrects it — a real, if brief, flash of wrong content: a tab that briefly shows the wrong panel before a `?tab=` URL param corrects it, an accordion that renders closed before a `#hash` deep-link expands it, a dialog that pops open at the wrong scroll position for a frame before jumping to the right slide. Use `useIsomorphicLayoutEffect` (`src/Utility/Hooks/useIsomorphicLayoutEffect.ts`) instead of a bare `useEffect` for these — it's `useLayoutEffect` in the browser (runs before paint) and falls back to `useEffect` during server rendering. The isomorphic fallback matters here specifically: a bare `useLayoutEffect` logs a React warning if it ever actually executes on the server, and SSR/hydration is a real, documented consumption pattern for this library (see [Troubleshooting.md](./Troubleshooting.md)'s "React hydration errors" section) — don't import `useLayoutEffect` from `"react"` directly in a component.
|
|
629
|
+
|
|
630
|
+
```typescript
|
|
631
|
+
import useIsomorphicLayoutEffect from "../../Utility/Hooks/useIsomorphicLayoutEffect"
|
|
632
|
+
|
|
633
|
+
useIsomorphicLayoutEffect(() => {
|
|
634
|
+
// eslint-disable-next-line @eslint-react/hooks-extra/no-direct-set-state-in-use-effect
|
|
635
|
+
// oxlint-disable-next-line react/set-state-in-effect -- synchronizes state with the URL, an external system
|
|
636
|
+
setActiveTab(tabParam)
|
|
637
|
+
}, [tabParam])
|
|
638
|
+
```
|
|
639
|
+
|
|
640
|
+
Web Components don't need this distinction — `connectedCallback`/`attributeChangedCallback` already run synchronously as part of the DOM update, with no passive-vs-layout effect split to get wrong.
|
|
641
|
+
|
|
642
|
+
**Only convert effects with a genuine flash risk.** Most effects — data fetching, subscribing to events, async measurement, cleanup-only — are correctly `useEffect` and gain nothing from running earlier. A cautionary tale: an attempt to fix Carousel's initial nav-button visibility this way backfired. Carousel already measures overflow via a `ResizeObserver`, whose first callback fires close to paint time on its own; adding a *second*, layout-effect-driven call to the same measurement duplicated that work, and for the pagination/counter variants it duplicated an untracked `setTimeout` per mount too — which measurably delayed unrelated stories' tests when several ran together in the same file. That change was reverted. Don't reach for `useIsomorphicLayoutEffect` where a native API (an observer, a browser default) already does the same measurement early enough on its own — only add it where nothing else does.
|
|
643
|
+
|
|
644
|
+
---
|
|
645
|
+
|
|
646
|
+
## Best Practices
|
|
647
|
+
|
|
648
|
+
### Do's ✅
|
|
649
|
+
|
|
650
|
+
1. **Use semantic HTML** - `<nav>`, `<main>`, `<article>`, `<section>`
|
|
651
|
+
2. **Validate DOM queries** - Throw errors with helpful messages
|
|
652
|
+
3. **Generate IDs when missing** - Use makeAnchor utility
|
|
653
|
+
4. **Clean up resources** - Always implement cleanup
|
|
654
|
+
5. **Use arrow functions** for handlers - Preserves `this` binding
|
|
655
|
+
6. **Add ARIA attributes** - Support screen readers
|
|
656
|
+
7. **Support keyboard navigation** - Tab, Enter, Escape, Arrows
|
|
657
|
+
8. **Document props** - JSDoc comments for complex props
|
|
658
|
+
9. **Write stories** - For every component variant
|
|
659
|
+
10. **Test accessibility** - Use Storybook a11y addon
|
|
660
|
+
|
|
661
|
+
### Don'ts ❌
|
|
662
|
+
|
|
663
|
+
1. **Don't forget cleanup** - Always disconnect observers
|
|
664
|
+
2. **Don't use inline styles** - Use CSS classes
|
|
665
|
+
3. **Don't hardcode IDs** - Generate or allow customization
|
|
666
|
+
4. **Don't ignore errors** - Log in development, handle in production
|
|
667
|
+
5. **Don't use `any` type** - Use proper TypeScript types
|
|
668
|
+
6. **Don't nest BEM classes** - Keep selectors flat
|
|
669
|
+
7. **Don't mutate props** - Props are read-only
|
|
670
|
+
8. **Don't use `innerHTML`** - XSS vulnerability
|
|
671
|
+
9. **Don't forget mobile** - Mobile-first responsive design
|
|
672
|
+
10. **Don't skip documentation** - Update README with changes
|
|
673
|
+
11. **Don't loop over repeatable content in Twig** - Pass a pre-joined string instead; see [Twig Templates](#twig-templates)
|
|
674
|
+
12. **Don't use a bare `useEffect` when it sets state that's part of the same render's visible output** - use `useIsomorphicLayoutEffect` instead, and don't duplicate a measurement a native observer/API already does early on its own; see [Effect Timing Pattern](#effect-timing-pattern-useeffect-vs-uselayouteffect)
|
|
675
|
+
|
|
676
|
+
---
|
|
677
|
+
|
|
678
|
+
## Component Checklist
|
|
679
|
+
|
|
680
|
+
Before submitting a new component, verify:
|
|
681
|
+
|
|
682
|
+
- [ ] TypeScript types defined
|
|
683
|
+
- [ ] Props have defaults where appropriate
|
|
684
|
+
- [ ] Cleanup implemented (disconnectedCallback/useEffect cleanup)
|
|
685
|
+
- [ ] Input validation using validators utility
|
|
686
|
+
- [ ] Error handling with try-catch
|
|
687
|
+
- [ ] Environment-specific logging
|
|
688
|
+
- [ ] BEM class names
|
|
689
|
+
- [ ] Responsive styles with custom media
|
|
690
|
+
- [ ] Logical CSS properties
|
|
691
|
+
- [ ] ARIA labels and roles
|
|
692
|
+
- [ ] Keyboard navigation support
|
|
693
|
+
- [ ] React component (if applicable)
|
|
694
|
+
- [ ] Web Component (if applicable)
|
|
695
|
+
- [ ] Twig template (if applicable) — repeatable content passed as a joined string, not an array/`{% for %}` loop
|
|
696
|
+
- [ ] Storybook stories created
|
|
697
|
+
- [ ] README documentation
|
|
698
|
+
- [ ] Snapshot tests passing
|
|
699
|
+
- [ ] No TypeScript errors
|
|
700
|
+
- [ ] Linting passes (`pnpm lint`)
|
|
701
|
+
- [ ] Manual testing in Storybook
|
|
702
|
+
- [ ] Accessibility tested (a11y addon)
|
|
703
|
+
- [ ] Mobile tested
|
|
704
|
+
- [ ] Cross-browser tested
|
|
705
|
+
|
|
706
|
+
---
|
|
707
|
+
|
|
708
|
+
## Related Documentation
|
|
709
|
+
|
|
710
|
+
- [Code Quality Guidelines](./CodeQuality.md)
|
|
711
|
+
- [Memory Management](./MemoryManagement.md)
|
|
712
|
+
- [CSS Guidelines](./CSSGuidelines.md)
|
|
713
|
+
- [Testing Guide](./Testing.md)
|
|
714
|
+
- [API Documentation](./API.md)
|