newspack-components 4.6.3 → 4.7.0-alpha.2

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 (148) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/colors/colors.module.scss +13 -7
  3. package/dist/cjs/autocomplete-tokenfield/style.scss +4 -2
  4. package/dist/cjs/breadcrumbs/format-count.js +33 -0
  5. package/dist/cjs/breadcrumbs/index.js +78 -20
  6. package/dist/cjs/breadcrumbs/index.test.js +110 -0
  7. package/dist/cjs/breadcrumbs/style.scss +6 -0
  8. package/dist/cjs/button/index.js +41 -17
  9. package/dist/cjs/button/index.test.js +273 -0
  10. package/dist/cjs/card/core-card.js +9 -4
  11. package/dist/cjs/card/core-card.test.js +29 -0
  12. package/dist/cjs/card/index.js +4 -2
  13. package/dist/cjs/card/style-core.scss +59 -0
  14. package/dist/cjs/card-feature/index.js +7 -1
  15. package/dist/cjs/card-feature/index.test.js +92 -0
  16. package/dist/cjs/color-picker/index.js +1 -1
  17. package/dist/cjs/confirm-dialog/index.js +26 -9
  18. package/dist/cjs/confirm-dialog/index.test.js +176 -0
  19. package/dist/cjs/dataviews/index.js +11 -2
  20. package/dist/cjs/divider/index.js +13 -0
  21. package/dist/cjs/drawer/action.js +74 -0
  22. package/dist/cjs/drawer/body.js +79 -0
  23. package/dist/cjs/drawer/close-icon.js +45 -0
  24. package/dist/cjs/drawer/content.js +72 -0
  25. package/dist/cjs/drawer/context.js +23 -0
  26. package/dist/cjs/drawer/context.test.js +43 -0
  27. package/dist/cjs/drawer/divider.js +24 -0
  28. package/dist/cjs/drawer/footer.js +26 -0
  29. package/dist/cjs/drawer/header.js +26 -0
  30. package/dist/cjs/drawer/index.js +42 -0
  31. package/dist/cjs/drawer/index.test.js +1287 -0
  32. package/dist/cjs/drawer/root.js +350 -0
  33. package/dist/cjs/drawer/slots.test.js +422 -0
  34. package/dist/cjs/drawer/style.scss +205 -0
  35. package/dist/cjs/drawer/title.js +54 -0
  36. package/dist/cjs/drawer/types.js +5 -0
  37. package/dist/cjs/drawer/use-exit-animation.js +59 -0
  38. package/dist/cjs/hooks/use-confirm-dialog.js +15 -3
  39. package/dist/cjs/hooks/use-unsaved-changes-dialog.js +19 -4
  40. package/dist/cjs/index.js +28 -1
  41. package/dist/cjs/page/index.js +1 -1
  42. package/dist/cjs/page-control/index.js +82 -0
  43. package/dist/cjs/plugin-settings/SettingsSection.js +7 -1
  44. package/dist/cjs/section-header/index.js +5 -2
  45. package/dist/cjs/section-header/style.scss +25 -12
  46. package/dist/cjs/table-card/index.js +67 -0
  47. package/dist/cjs/table-card/index.test.js +153 -0
  48. package/dist/cjs/with-wizard-screen/style.scss +8 -0
  49. package/dist/cjs/wizard/index.js +102 -38
  50. package/dist/esm/autocomplete-tokenfield/style.scss +4 -2
  51. package/dist/esm/breadcrumbs/format-count.js +27 -0
  52. package/dist/esm/breadcrumbs/index.js +81 -20
  53. package/dist/esm/breadcrumbs/index.test.js +110 -0
  54. package/dist/esm/breadcrumbs/style.scss +6 -0
  55. package/dist/esm/button/index.js +42 -18
  56. package/dist/esm/button/index.test.js +273 -0
  57. package/dist/esm/card/core-card.js +9 -4
  58. package/dist/esm/card/core-card.test.js +29 -0
  59. package/dist/esm/card/index.js +4 -2
  60. package/dist/esm/card/style-core.scss +59 -0
  61. package/dist/esm/card-feature/index.js +8 -2
  62. package/dist/esm/card-feature/index.test.js +96 -0
  63. package/dist/esm/color-picker/index.js +1 -1
  64. package/dist/esm/confirm-dialog/index.js +26 -9
  65. package/dist/esm/confirm-dialog/index.test.js +176 -0
  66. package/dist/esm/dataviews/index.js +11 -2
  67. package/dist/esm/divider/index.js +14 -0
  68. package/dist/esm/drawer/action.js +68 -0
  69. package/dist/esm/drawer/body.js +70 -0
  70. package/dist/esm/drawer/close-icon.js +37 -0
  71. package/dist/esm/drawer/content.js +63 -0
  72. package/dist/esm/drawer/context.js +17 -0
  73. package/dist/esm/drawer/context.test.js +40 -0
  74. package/dist/esm/drawer/divider.js +16 -0
  75. package/dist/esm/drawer/footer.js +18 -0
  76. package/dist/esm/drawer/header.js +18 -0
  77. package/dist/esm/drawer/index.js +35 -0
  78. package/dist/esm/drawer/index.test.js +1285 -0
  79. package/dist/esm/drawer/root.js +342 -0
  80. package/dist/esm/drawer/slots.test.js +422 -0
  81. package/dist/esm/drawer/style.scss +205 -0
  82. package/dist/esm/drawer/title.js +46 -0
  83. package/dist/esm/drawer/types.js +1 -0
  84. package/dist/esm/drawer/use-exit-animation.js +52 -0
  85. package/dist/esm/hooks/use-confirm-dialog.js +15 -3
  86. package/dist/esm/hooks/use-unsaved-changes-dialog.js +20 -5
  87. package/dist/esm/index.js +4 -0
  88. package/dist/esm/page/index.js +1 -1
  89. package/dist/esm/page-control/index.js +82 -0
  90. package/dist/esm/plugin-settings/SettingsSection.js +8 -2
  91. package/dist/esm/section-header/index.js +5 -2
  92. package/dist/esm/section-header/style.scss +25 -12
  93. package/dist/esm/table-card/index.js +60 -0
  94. package/dist/esm/table-card/index.test.js +150 -0
  95. package/dist/esm/with-wizard-screen/style.scss +8 -0
  96. package/dist/esm/wizard/index.js +104 -38
  97. package/package.json +2 -2
  98. package/src/autocomplete-tokenfield/style.scss +4 -2
  99. package/src/breadcrumbs/format-count.js +27 -0
  100. package/src/breadcrumbs/index.js +68 -8
  101. package/src/breadcrumbs/index.test.js +57 -0
  102. package/src/breadcrumbs/style.scss +6 -0
  103. package/src/button/index.test.js +143 -0
  104. package/src/button/index.tsx +32 -7
  105. package/src/card/core-card.js +15 -2
  106. package/src/card/core-card.test.js +19 -0
  107. package/src/card/index.js +3 -0
  108. package/src/card/style-core.scss +59 -0
  109. package/src/card-feature/README.md +4 -0
  110. package/src/card-feature/index.test.js +65 -0
  111. package/src/card-feature/index.tsx +13 -2
  112. package/src/color-picker/index.js +1 -1
  113. package/src/confirm-dialog/README.md +3 -2
  114. package/src/confirm-dialog/index.test.js +146 -0
  115. package/src/confirm-dialog/index.tsx +23 -9
  116. package/src/dataviews/index.tsx +11 -2
  117. package/src/divider/index.js +13 -0
  118. package/src/drawer/README.md +454 -0
  119. package/src/drawer/action.tsx +67 -0
  120. package/src/drawer/body.tsx +60 -0
  121. package/src/drawer/close-icon.tsx +37 -0
  122. package/src/drawer/content.tsx +54 -0
  123. package/src/drawer/context.test.js +31 -0
  124. package/src/drawer/context.ts +25 -0
  125. package/src/drawer/divider.tsx +13 -0
  126. package/src/drawer/footer.tsx +15 -0
  127. package/src/drawer/header.tsx +15 -0
  128. package/src/drawer/index.test.js +1012 -0
  129. package/src/drawer/index.tsx +33 -0
  130. package/src/drawer/root.tsx +328 -0
  131. package/src/drawer/slots.test.js +314 -0
  132. package/src/drawer/style.scss +205 -0
  133. package/src/drawer/title.tsx +40 -0
  134. package/src/drawer/types.ts +79 -0
  135. package/src/drawer/use-exit-animation.ts +46 -0
  136. package/src/hooks/use-confirm-dialog.tsx +14 -3
  137. package/src/hooks/use-unsaved-changes-dialog.tsx +17 -5
  138. package/src/index.js +4 -0
  139. package/src/page/index.js +1 -1
  140. package/src/page-control/index.js +63 -0
  141. package/src/plugin-settings/SettingsSection.js +8 -2
  142. package/src/section-header/index.js +5 -2
  143. package/src/section-header/style.scss +25 -12
  144. package/src/table-card/README.md +100 -0
  145. package/src/table-card/index.test.js +112 -0
  146. package/src/table-card/index.tsx +56 -0
  147. package/src/with-wizard-screen/style.scss +8 -0
  148. package/src/wizard/index.js +95 -34
@@ -0,0 +1,454 @@
1
+ # Drawer
2
+
3
+ A modal panel that slides in from the right edge and spans the full height of
4
+ the viewport. The page behind it is inert.
5
+
6
+ The API is compound: a `Drawer.Root` and one subcomponent per slot. It mirrors
7
+ the `Drawer` that ships in `@wordpress/ui`, so adopting that package later, once
8
+ it stabilises, is close to a rename inside this wrapper. `Drawer.Divider` is
9
+ the one part with no counterpart there.
10
+
11
+ The parts hang off one exported object rather than the flat named exports the
12
+ rest of this package uses. That is deliberate, to keep them recognisable against
13
+ `@wordpress/ui`'s own `Drawer`, and not a pattern for the next compound
14
+ component to copy.
15
+
16
+ ```jsx
17
+ import { Drawer } from 'newspack-components';
18
+ ```
19
+
20
+ The component imports its own stylesheet, so the barrel ships the CSS with it.
21
+ There is nothing separate to import.
22
+
23
+ ## Keep it mounted
24
+
25
+ **`Drawer.Root` takes `isOpen` and stays mounted.** It owns its own exit
26
+ animation, so it has to still be in the tree while the slide-out plays.
27
+
28
+ ```jsx
29
+ // Yes.
30
+ <Drawer.Root isOpen={ isOpen } onRequestClose={ close }>
31
+ { slots }
32
+ </Drawer.Root>
33
+
34
+ // No. Unmounting cuts the slide-out, and the panel vanishes instead.
35
+ { isOpen && <Drawer.Root isOpen onRequestClose={ close }>{ slots }</Drawer.Root> }
36
+ ```
37
+
38
+ It renders nothing while closed, so leaving it mounted costs nothing.
39
+
40
+ ## Usage
41
+
42
+ ```jsx
43
+ import { __ } from '@wordpress/i18n';
44
+ import { useState } from '@wordpress/element';
45
+ import { Icon, settings } from '@wordpress/icons';
46
+ import { Drawer } from 'newspack-components';
47
+
48
+ const [ isOpen, setIsOpen ] = useState( false );
49
+
50
+ <Drawer.Root isOpen={ isOpen } size="large" isDirty={ isDirty } onRequestClose={ () => setIsOpen( false ) }>
51
+ <Drawer.Header>
52
+ <Icon className="newspack-drawer__icon" icon={ settings } size={ 24 } />
53
+ <Drawer.Title>{ __( 'Edit Styles', 'newspack-plugin' ) }</Drawer.Title>
54
+ <Drawer.CloseIcon />
55
+ </Drawer.Header>
56
+ <Drawer.Content>{ fields }</Drawer.Content>
57
+ <Drawer.Divider />
58
+ <Drawer.Content padding={ 0 }>{ flushTable }</Drawer.Content>
59
+ <Drawer.Footer>
60
+ <Drawer.Action variant="secondary" closes>
61
+ { __( 'Cancel', 'newspack-plugin' ) }
62
+ </Drawer.Action>
63
+ <Drawer.Action variant="primary" isBusy={ inFlight } onClick={ save }>
64
+ { __( 'Save', 'newspack-plugin' ) }
65
+ </Drawer.Action>
66
+ </Drawer.Footer>
67
+ </Drawer.Root>
68
+ ```
69
+
70
+ Every slot is optional and nothing is composed for you: a drawer with no
71
+ `Drawer.Header` renders no header markup at all.
72
+
73
+ ## `Drawer.Root`
74
+
75
+ Owns the panel, the close funnel and the confirmation. Everything behavioural
76
+ lives here.
77
+
78
+ | Prop | Type | Default | Description |
79
+ |------|------|---------|-------------|
80
+ | `children` | `React.ReactNode` | — | The slots. See the constraint under [`Drawer.Content`](#drawercontent). |
81
+ | `className` | `string` | — | Additional CSS class on the panel. |
82
+ | `confirmButtonText` | `string` | `'Discard Changes'` | Confirm label on the built-in unsaved-changes dialog. |
83
+ | `confirmCloseMessage` | `React.ReactNode` | `'You have unsaved changes that will be lost. Discard changes?'` | Body of the built-in unsaved-changes dialog. |
84
+ | `contentLabel` | `string` | — | Accessible name when the design has no visible title. Ignored when a `Drawer.Title` is rendered. |
85
+ | `describedBy` | `string` | — | Id of an element describing the panel, for the frame's `aria-describedby`. |
86
+ | `isDirty` | `boolean` | `false` | Routes every close through a confirmation. See [Closing and confirmation](#closing-and-confirmation). |
87
+ | `isOpen` | `boolean` | `false` | Whether the panel is showing. Keep the Root mounted either way. |
88
+ | `onRequestClose` | `() => void` | — | **Required.** Called once a close is confirmed. |
89
+ | `requestConfirm` | `( callback: () => void ) => void` | — | Delegates confirmation to a dialog you already own. |
90
+ | `ref` | `React.Ref< HTMLDivElement >` | — | Lands on the overlay, which is what core Modal forwards a ref to. |
91
+ | `size` | `'small'` \| `'medium'` \| `'large'` \| `'x-large'` \| `'full'` | `'medium'` | Panel width. An unknown value falls back to `medium` and warns in development. |
92
+ | `style` | `React.CSSProperties` | — | Merged into the panel's own style. |
93
+
94
+ ### Sizes
95
+
96
+ | `size` | Width |
97
+ |---|---|
98
+ | `small` | 280px |
99
+ | `medium` (default) | 350px |
100
+ | `large` | 480px |
101
+ | `x-large` | 640px |
102
+ | `full` | 100vw |
103
+
104
+ At 600px and below every size goes full width.
105
+
106
+ ## `Drawer.Header`
107
+
108
+ A flex row, pinned above the body. Compose an icon, a `Drawer.Title` and a
109
+ `Drawer.CloseIcon` inside it; the order is yours. Takes `className` and
110
+ `children`.
111
+
112
+ Give a leading icon `className="newspack-drawer__icon"`, which stops it
113
+ shrinking and fills it with `currentcolor`.
114
+
115
+ ## `Drawer.Title`
116
+
117
+ Renders an `h2` and registers its id with the Root, which wires it to the
118
+ panel's `aria-labelledby`. Takes `className` and `children`.
119
+
120
+ A single plain-string child also registers its text, and `Drawer.CloseIcon`
121
+ composes that into "Close {title}". Mixed children are not a string, so the
122
+ close button falls back to a bare "Close". Build the string first to keep the
123
+ composed label.
124
+
125
+ ```jsx
126
+ // The close button is named "Close Edit Styles".
127
+ <Drawer.Title>{ __( 'Edit Styles', 'newspack-plugin' ) }</Drawer.Title>
128
+
129
+ // Interpolated, so the children are an array and the close button is
130
+ // named "Close".
131
+ <Drawer.Title>{ __( 'Edit', 'newspack-plugin' ) } { name }</Drawer.Title>
132
+
133
+ // One string child again, so the composed label is back.
134
+ // translators: %s: the item being edited.
135
+ <Drawer.Title>{ sprintf( __( 'Edit %s', 'newspack-plugin' ), name ) }</Drawer.Title>
136
+ ```
137
+
138
+ ## `Drawer.CloseIcon`
139
+
140
+ An icon-only button. Clicking it goes through the Root's close funnel, so the
141
+ unsaved-changes confirmation always applies.
142
+
143
+ | Prop | Type | Default | Description |
144
+ |------|------|---------|-------------|
145
+ | `className` | `string` | — | Additional CSS class. |
146
+ | `icon` | `JSX.Element` | `close` from `@wordpress/icons` | Icon to render. |
147
+ | `label` | `string` | `'Close {title}'`, or `'Close'` without one | Tooltip and accessible name. |
148
+
149
+ ## `Drawer.Content`
150
+
151
+ The body. Repeatable: each `Drawer.Content` is a section, rendered as a VStack
152
+ with a 16px gap by default; `gap` changes it. Children space through the gap
153
+ alone; their own top and bottom margins are reset.
154
+
155
+ Anything can go in a section: an element, a control, a nested stack, or plain
156
+ text. Each child becomes a row separated by the gap, and a run of plain text and
157
+ string interpolations stays one row, so `Edited by { name }` reads as a sentence
158
+ rather than stacking. Whitespace between two elements is not a row of its own.
159
+
160
+ An element always starts a new row, including inside a sentence: `Edited by
161
+ <strong>{ name }</strong>` is two rows, not one. Wrap a sentence that mixes text
162
+ with markup in your own element and hand the section that instead.
163
+
164
+ | Prop | Type | Default | Description |
165
+ |------|------|---------|-------------|
166
+ | `children` | `React.ReactNode` | — | Section content. |
167
+ | `className` | `string` | — | Additional CSS class. |
168
+ | `gap` | `number` | `4` | Space between the section's children, on the 4px scale, as VStack's `spacing`. `4` is 16px. |
169
+ | `padding` | `number` | `6` | On the 4px scale, as VStack's `spacing`. `6` is 24px; `0` is a flush section that brings its own padding. |
170
+
171
+ Consecutive sections share one scroll container, so they scroll together between
172
+ the pinned header and footer. Sections are not self-separating: put a
173
+ [`Drawer.Divider`](#drawerdivider) between two of them to draw a rule. The
174
+ first section drops its top padding when a header precedes it, and the last
175
+ section drops its bottom padding when a footer follows, so the seam at each end
176
+ is the chrome's own 16px padding. Without that chrome the section keeps its own
177
+ padding and sits against the edge of the panel.
178
+
179
+ **Sections must be direct children of `Drawer.Root`.** The Root walks its direct
180
+ children to build that scroll container. A section nested in a fragment or any
181
+ other wrapper is passed straight through and never grouped, silently. Arrays are
182
+ fine.
183
+
184
+ ```jsx
185
+ // Yes.
186
+ <Drawer.Root isOpen={ isOpen } onRequestClose={ close }>
187
+ <Drawer.Content>{ fields }</Drawer.Content>
188
+ { hasTable && <Drawer.Content padding={ 0 }>{ table }</Drawer.Content> }
189
+ </Drawer.Root>
190
+
191
+ // No. The fragment is one non-Content child, so neither section is grouped.
192
+ <Drawer.Root isOpen={ isOpen } onRequestClose={ close }>
193
+ <>
194
+ <Drawer.Content>{ fields }</Drawer.Content>
195
+ <Drawer.Content padding={ 0 }>{ table }</Drawer.Content>
196
+ </>
197
+ </Drawer.Root>
198
+ ```
199
+
200
+ **Guard a conditional section on a boolean.** `null`, `undefined` and `false`
201
+ are dropped and leave the run intact, but `0` and `''` are not: they count as
202
+ non-Content children, so `{ items.length && <Drawer.Content/> }` with an empty
203
+ array renders a stray "0" in the drawer and splits the run in two.
204
+
205
+ ```jsx
206
+ // Yes.
207
+ { !! items.length && <Drawer.Content>{ list }</Drawer.Content> }
208
+ { items.length ? <Drawer.Content>{ list }</Drawer.Content> : null }
209
+
210
+ // No. An empty array leaves a "0" in the drawer and a split body.
211
+ { items.length && <Drawer.Content>{ list }</Drawer.Content> }
212
+ ```
213
+
214
+ A child that is neither a `Drawer.Content` nor a `Drawer.Divider` splits the
215
+ sections around it into two scroll containers. Each container is keyed by its
216
+ position among the bodies, not among the children, so a drawer with one body
217
+ keeps it, and its scroll position, when a sibling above it is toggled. With
218
+ several bodies, a change that merges or splits an earlier run re-keys the ones
219
+ after it, which resets their scroll.
220
+
221
+ ## `Drawer.Divider`
222
+
223
+ A full-width rule between two sections. It joins the run, so it sits inside the
224
+ same scroll container and travels with the sections rather than splitting them.
225
+
226
+ Takes `className`. Renders an `<hr>`, so assistive technology announces it as a
227
+ separator.
228
+
229
+ ```jsx
230
+ <Drawer.Content>{ fields }</Drawer.Content>
231
+ <Drawer.Divider />
232
+ <Drawer.Content>{ moreFields }</Drawer.Content>
233
+ ```
234
+
235
+ ## `Drawer.Footer`
236
+
237
+ A pinned container for `Drawer.Action` elements. The layout is pure CSS on the
238
+ child count.
239
+
240
+ | Children | Layout |
241
+ |---|---|
242
+ | 1 | Row, the button fills it |
243
+ | 2 | Row, split 50/50 |
244
+ | 3 or more | Column, full-width buttons |
245
+
246
+ Convention, not enforced: with two actions pass the secondary first, so the
247
+ primary sits on the right; with three pass primary, secondary, tertiary
248
+ top-down.
249
+
250
+ Takes `className` and `children`.
251
+
252
+ ## `Drawer.Action`
253
+
254
+ A footer button, wrapping the Newspack `Button` so `href` and router behaviour
255
+ work as they do elsewhere. Children are the visible label.
256
+
257
+ | Prop | Type | Default | Description |
258
+ |------|------|---------|-------------|
259
+ | `ariaLabel` | `string` | — | Extends the visible label with context. Must contain it. |
260
+ | `children` | `React.ReactNode` | — | The visible label. |
261
+ | `className` | `string` | — | Additional CSS class. |
262
+ | `closes` | `boolean` | `false` | Requests the drawer close through the Root's funnel after `onClick`, so the unsaved-changes confirmation applies. |
263
+ | `disabled` | `boolean` | — | Disables the button. |
264
+ | `href` | `string` | — | Renders a link instead of a button. Cannot be combined with `onClick` or `closes`. |
265
+ | `isBusy` | `boolean` | — | Busy styling, for a save in flight. |
266
+ | `isDestructive` | `boolean` | — | Destructive styling. |
267
+ | `onClick` | `( event?: React.MouseEvent ) => void` | — | Click handler. Receives the event. |
268
+ | `variant` | `'primary'` \| `'secondary'` \| `'tertiary'` \| `'link'` | — | Button variant. |
269
+
270
+ `ariaLabel` renders as a plain `aria-label`, not the WP `Button` `label` that
271
+ would put a tooltip on a button which already has visible text.
272
+
273
+ An action either navigates or acts. `href` with `onClick` or `closes` is a type
274
+ error: the underlying `Button` drops the `href` as soon as a handler is present,
275
+ and a link that also runs the close funnel would navigate away while the
276
+ unsaved-changes confirmation was still opening.
277
+
278
+ Without `closes`, an action runs the `onClick` you give it and nothing else,
279
+ and `isDirty` does not cover it. Give a Cancel or Close action `closes` so it
280
+ goes through the same funnel as `Drawer.CloseIcon`; leave it off a Save, which
281
+ should close by flipping `isOpen` once its work succeeds.
282
+
283
+ ## Closing and confirmation
284
+
285
+ Every close route, the `Drawer.CloseIcon`, Escape and an overlay press, goes
286
+ through one handler in the Root. Core Modal's own close paths are switched off,
287
+ so nothing can close the panel behind that handler.
288
+
289
+ **`isDirty` false.** The handler calls `onRequestClose`, the parent sets `isOpen`
290
+ false, and the slide-out plays.
291
+
292
+ **`isDirty` true.** The handler opens the confirm dialog above the drawer, which
293
+ has not moved. "Discard Changes" calls `onRequestClose`, and only then does the
294
+ slide-out play. Cancel does nothing and the panel is still where it was. A
295
+ refused close never animates.
296
+
297
+ Raise `isDirty` while a save is in flight too, so an in-progress request cannot
298
+ be closed out from under.
299
+
300
+ ```jsx
301
+ <Drawer.Root isOpen={ isOpen } isDirty={ isDirty || inFlight } onRequestClose={ close }>
302
+ { slots }
303
+ </Drawer.Root>
304
+ ```
305
+
306
+ **Footer actions with `closes` are covered.** `closes` sends the action through
307
+ the same handler as the close icon, so a Cancel written that way gets the
308
+ confirmation and a Save without it does not.
309
+
310
+ ```jsx
311
+ <Drawer.Footer>
312
+ <Drawer.Action variant="secondary" closes>{ __( 'Cancel', 'newspack-plugin' ) }</Drawer.Action>
313
+ <Drawer.Action variant="primary" onClick={ save }>{ __( 'Save', 'newspack-plugin' ) }</Drawer.Action>
314
+ </Drawer.Footer>
315
+ ```
316
+
317
+ The action's own `onClick` runs *before* the handler, so its side effects survive
318
+ a cancelled confirmation. Keep anything that must not happen twice out of an
319
+ action that also closes.
320
+
321
+ **Another modal opening closes a clean panel, and leaves a dirty one alone.**
322
+ Core shows one modal at a time and dismisses the drawer as a sibling mounts. A
323
+ clean panel takes that and closes. A dirty one does not answer it at all: its own
324
+ confirmation would render underneath the modal that just opened, and a delegated
325
+ confirmation *is* the modal that triggered the dismissal. So a dirty panel stays
326
+ open behind the new dialog and is still there once it is dealt with.
327
+
328
+ **Inside a wizard that already runs `useUnsavedChangesDialog`, pass its
329
+ `requestConfirm`.** The Root then delegates and mounts no dialog of its own. Two
330
+ live dialogs prompt each other: the Root's cancel path calls `history.replace`,
331
+ which the wizard's navigation blocker catches.
332
+
333
+ **The guard's `when` has to cover everything the Root's `isDirty` covers.** A
334
+ delegated `requestConfirm` runs its callback immediately, with no dialog, when
335
+ its own `when` is false. The Root cannot see that flag, so a panel whose
336
+ `isDirty` is true while the guard's `when` is false closes with no prompt and
337
+ loses the edits. Drive both from the same state, as below, or widen `when` to
338
+ include the panel's. The Root warns in development when it notices a delegated
339
+ confirmation closing the panel without prompting.
340
+
341
+ **A delegated dialog is yours to dismiss.** The Root only ever withdraws the one
342
+ it owns. If the panel closes while your prompt is up — a save that lands while
343
+ the user is still looking at it — the prompt stays, and answering it calls
344
+ `onRequestClose` on a panel that has already gone. Call the hook's
345
+ `cancelConfirm` yourself when you close the panel out from under it. The Root
346
+ deliberately does not: the same dialog serves the whole wizard, and it has no way
347
+ to tell a prompt it raised from one your own navigation guard raised.
348
+
349
+ ```jsx
350
+ const { confirmDialog, requestConfirm, cancelConfirm } = useUnsavedChangesDialog( { when: isDirty } );
351
+
352
+ const save = async () => {
353
+ await persist();
354
+ // The panel closes on its own here, so withdraw the prompt along with it.
355
+ cancelConfirm();
356
+ setIsOpen( false );
357
+ };
358
+
359
+ <Drawer.Root
360
+ isOpen={ isOpen }
361
+ isDirty={ isDirty }
362
+ requestConfirm={ requestConfirm }
363
+ onRequestClose={ close }
364
+ >
365
+ { slots }
366
+ </Drawer.Root>
367
+ { confirmDialog }
368
+ ```
369
+
370
+ ## Accessibility
371
+
372
+ **Name the panel.** A `Drawer.Title` supplies `aria-labelledby`; a `contentLabel`
373
+ on the Root supplies `aria-label` when the design has no visible title. With
374
+ neither, the Root warns in development: an unnamed dialog is announced without a
375
+ name when focus enters it.
376
+
377
+ **Give terse actions context.** "Save" alone answers neither "save what" nor
378
+ "save where" for someone who lands on the button directly. `ariaLabel` extends
379
+ the visible text:
380
+
381
+ ```jsx
382
+ <Drawer.Action variant="primary" ariaLabel={ __( 'Save styles', 'newspack-plugin' ) } onClick={ save }>
383
+ { __( 'Save', 'newspack-plugin' ) }
384
+ </Drawer.Action>
385
+ ```
386
+
387
+ `ariaLabel` **must contain** the visible label. Voice control matches spoken
388
+ commands against the accessible name, so a button reading "Save" whose name is
389
+ "Apply changes" stops responding to "click Save" (WCAG 2.5.3, Label in Name).
390
+ `Drawer.Action` warns in development when the visible string is missing from
391
+ `ariaLabel`. Icon-only actions are not supported, since there is no visible text
392
+ to extend.
393
+
394
+ The rule holds per locale, and the check only sees the translated strings. The
395
+ two land in the POT as unrelated entries, so give the `ariaLabel` a `translators:`
396
+ comment naming the visible label it has to contain — otherwise the guarantee is
397
+ lost in any locale where they drift apart, and the warning only fires for someone
398
+ running that locale in development.
399
+
400
+ ```jsx
401
+ /* translators: extended label for the Save button. Must contain the word "Save" as translated below. */
402
+ ariaLabel={ __( 'Save styles', 'newspack-plugin' ) }
403
+ ```
404
+
405
+ **Reduced motion.** Under `prefers-reduced-motion: reduce` the slide and the
406
+ scrim fade are dropped, and the panel is removed at once rather than waiting on
407
+ an animation that never runs.
408
+
409
+ **Scrolling without a mouse.** A scroll container that holds nothing focusable
410
+ cannot be reached from the keyboard in every browser, so the body takes
411
+ `tabindex="0"` and the "Scrollable section" label whenever it actually overflows,
412
+ and drops both when it does not (WCAG 2.1.1). Core Modal does this for its own
413
+ container, but the drawer scrolls in the body instead, leaving that one no taller
414
+ than its box. Overflow is measured on the body and its sections, so a section
415
+ that grows after mount is picked up.
416
+
417
+ ## Popovers
418
+
419
+ The Root wraps its children in a `SlotFillProvider` and renders a `Popover.Slot`
420
+ beside them, so dropdowns and pickers inside the drawer stay visible. Without
421
+ both, a popover
422
+ portalled to the body would land in a container Modal has marked `aria-hidden`,
423
+ and our own `modal/style.scss` blanks popovers while a modal is open.
424
+
425
+ That provider starts a fresh registry rather than chaining to a parent one. **A
426
+ `Fill` rendered inside the drawer cannot reach a `Slot` outside it**: it renders
427
+ nothing, silently. Move the `Slot` inside the drawer.
428
+
429
+ ## Outside the Root
430
+
431
+ `Drawer.Title` and `Drawer.CloseIcon` read the Root's private context and throw
432
+ "Drawer subcomponents must be rendered inside Drawer.Root." when they are
433
+ rendered anywhere else.
434
+
435
+ ## On a `@wordpress/components` upgrade
436
+
437
+ The panel reaches into core Modal in three places that are internals rather than
438
+ a documented contract, and the peer range is a caret. Check them on a bump:
439
+
440
+ - The Root finds the frame with `.components-modal__frame` inside the node Modal
441
+ forwards its ref to, and uses it for focus return and `inert`. It warns in
442
+ development when that lookup comes back empty.
443
+ - `__experimentalHideHeader` suppresses core's own header. Losing it puts a
444
+ second header above `Drawer.Header`.
445
+ - `style.scss` targets `.components-modal__content` and
446
+ `.components-modal__children-container` for the panel's layout.
447
+ - `shouldCloseOnEsc={ false }`, `shouldCloseOnClickOutside={ false }` and
448
+ `__experimentalHideHeader` are what leave core with no close path of its own.
449
+ Re-enabling any of them — a header for `headerActions`, say — gives core a way
450
+ to close a dirty panel with no confirmation.
451
+
452
+ Core Modal is externalised to the `wp.components` runtime global at build time,
453
+ so the version that actually runs is the one on the publisher's WordPress, not
454
+ the one resolved here.
@@ -0,0 +1,67 @@
1
+ /**
2
+ * WordPress dependencies.
3
+ */
4
+ import { useContext, useEffect } from '@wordpress/element';
5
+
6
+ /**
7
+ * Internal dependencies.
8
+ */
9
+ import Button from '../button';
10
+ import { DrawerContext } from './context';
11
+ import type { DrawerActionProps } from './types';
12
+
13
+ const Action = ( { ariaLabel, closes = false, onClick, href, children, ...buttonProps }: DrawerActionProps ) => {
14
+ // Not useDrawerContext: only `closes` requires the context.
15
+ const context = useContext( DrawerContext );
16
+
17
+ // `href=""` is still an anchor; a null from a JS caller means no link at all.
18
+ const hasHref = 'string' === typeof href;
19
+
20
+ useEffect( () => {
21
+ if ( 'production' === process.env.NODE_ENV || ! hasHref ) {
22
+ return;
23
+ }
24
+ if ( onClick || closes ) {
25
+ // eslint-disable-next-line no-console
26
+ console.warn(
27
+ 'Drawer: an action with `href` cannot also take `onClick` or `closes`, and they are ignored. ' +
28
+ 'A link that ran the close funnel would navigate away while the unsaved-changes confirmation was still opening.'
29
+ );
30
+ }
31
+ }, [ hasHref, onClick, closes ] );
32
+
33
+ useEffect( () => {
34
+ if ( 'production' === process.env.NODE_ENV ) {
35
+ return;
36
+ }
37
+ if ( typeof children === 'string' && ariaLabel && ! ariaLabel.toLowerCase().includes( children.toLowerCase() ) ) {
38
+ // eslint-disable-next-line no-console
39
+ console.warn(
40
+ `Drawer: action ariaLabel "${ ariaLabel }" does not contain its visible label "${ children }". ` +
41
+ 'Voice control matches spoken commands against the accessible name (WCAG 2.5.3, Label in Name), ' +
42
+ 'so ariaLabel must extend the visible text rather than replace it.'
43
+ );
44
+ }
45
+ }, [ ariaLabel, children ] );
46
+
47
+ const handleClick = ( event?: React.MouseEvent< HTMLElement > ) => {
48
+ onClick?.( event );
49
+ if ( closes ) {
50
+ if ( ! context ) {
51
+ throw new Error( 'Drawer subcomponents must be rendered inside Drawer.Root.' );
52
+ }
53
+ context.requestClose();
54
+ }
55
+ };
56
+
57
+ // Button swallows `href` whenever an onClick is present, so a link supplies none.
58
+ const handlesClick = ! hasHref && ( !! onClick || closes );
59
+
60
+ return (
61
+ <Button aria-label={ ariaLabel } href={ hasHref ? href : undefined } onClick={ handlesClick ? handleClick : undefined } { ...buttonProps }>
62
+ { children }
63
+ </Button>
64
+ );
65
+ };
66
+
67
+ export default Action;
@@ -0,0 +1,60 @@
1
+ /**
2
+ * WordPress dependencies.
3
+ */
4
+ import { __experimentalVStack as VStack } from '@wordpress/components'; // eslint-disable-line @wordpress/no-unsafe-wp-apis
5
+ import { useEffect, useState } from '@wordpress/element';
6
+ import { __ } from '@wordpress/i18n';
7
+
8
+ /**
9
+ * Internal dependencies.
10
+ */
11
+ import type { DrawerBodyProps } from './types';
12
+
13
+ // Core tab-stops its own container once it overflows; the drawer scrolls here.
14
+ const Body = ( { children }: DrawerBodyProps ) => {
15
+ const [ node, setNode ] = useState< HTMLElement | null >( null );
16
+ const [ isScrollable, setIsScrollable ] = useState( false );
17
+
18
+ useEffect( () => {
19
+ if ( ! node ) {
20
+ return;
21
+ }
22
+ const measure = () => setIsScrollable( node.scrollHeight > node.clientHeight );
23
+ measure();
24
+ if ( ! window.ResizeObserver ) {
25
+ return;
26
+ }
27
+ const resize = new ResizeObserver( measure );
28
+ const observeChildren = () => {
29
+ resize.disconnect();
30
+ resize.observe( node );
31
+ // Border box: a section's padding rides on an inline custom property.
32
+ Array.from( node.children ).forEach( child => resize.observe( child, { box: 'border-box' } ) );
33
+ measure();
34
+ };
35
+ observeChildren();
36
+ const mutation = new MutationObserver( observeChildren );
37
+ mutation.observe( node, { childList: true } );
38
+ return () => {
39
+ resize.disconnect();
40
+ mutation.disconnect();
41
+ };
42
+ }, [ node ] );
43
+
44
+ return (
45
+ <VStack
46
+ ref={ setNode }
47
+ className="newspack-drawer__body"
48
+ spacing={ 0 }
49
+ justify="flex-start"
50
+ // A bare div is role=generic, where ARIA prohibits an author name.
51
+ role={ isScrollable ? 'group' : undefined }
52
+ tabIndex={ isScrollable ? 0 : undefined }
53
+ aria-label={ isScrollable ? __( 'Scrollable section', 'newspack-plugin' ) : undefined }
54
+ >
55
+ { children }
56
+ </VStack>
57
+ );
58
+ };
59
+
60
+ export default Body;
@@ -0,0 +1,37 @@
1
+ /**
2
+ * WordPress dependencies.
3
+ */
4
+ import { close } from '@wordpress/icons';
5
+ import { __, sprintf } from '@wordpress/i18n';
6
+
7
+ /**
8
+ * External dependencies.
9
+ */
10
+ import classnames from 'classnames';
11
+
12
+ /**
13
+ * Internal dependencies.
14
+ */
15
+ import Button from '../button';
16
+ import { useDrawerContext } from './context';
17
+ import type { DrawerCloseIconProps } from './types';
18
+
19
+ const CloseIcon = ( { label, icon = close, className }: DrawerCloseIconProps ) => {
20
+ const { requestClose, title } = useDrawerContext();
21
+ const defaultLabel = title?.text
22
+ ? // translators: %s: the drawer's title.
23
+ sprintf( __( 'Close %s', 'newspack-plugin' ), title.text )
24
+ : __( 'Close', 'newspack-plugin' );
25
+
26
+ return (
27
+ <Button
28
+ className={ classnames( 'newspack-drawer__dismiss', className ) }
29
+ icon={ icon }
30
+ size="small"
31
+ label={ label || defaultLabel }
32
+ onClick={ requestClose }
33
+ />
34
+ );
35
+ };
36
+
37
+ export default CloseIcon;
@@ -0,0 +1,54 @@
1
+ /**
2
+ * WordPress dependencies.
3
+ */
4
+ import { __experimentalVStack as VStack } from '@wordpress/components'; // eslint-disable-line @wordpress/no-unsafe-wp-apis
5
+ import { Children, isValidElement } from '@wordpress/element';
6
+
7
+ /**
8
+ * External dependencies.
9
+ */
10
+ import classnames from 'classnames';
11
+
12
+ /**
13
+ * Internal dependencies.
14
+ */
15
+ import type { DrawerContentProps } from './types';
16
+
17
+ // VStack drops non-elements, a lone string aside. A run shares one wrapper so
18
+ // `Edited by { name }` is one row, not three.
19
+ const asRows = ( children: React.ReactNode ) => {
20
+ if ( 'string' === typeof children ) {
21
+ return children;
22
+ }
23
+ const rows: React.ReactNode[] = [];
24
+ let text: React.ReactNode[] = [];
25
+ const flushText = () => {
26
+ if ( text.some( part => '' !== String( part ).trim() ) ) {
27
+ rows.push( <div key={ `text-${ rows.length }` }>{ text }</div> );
28
+ }
29
+ text = [];
30
+ };
31
+ Children.toArray( children ).forEach( child => {
32
+ if ( isValidElement( child ) ) {
33
+ flushText();
34
+ rows.push( child );
35
+ } else {
36
+ text.push( child );
37
+ }
38
+ } );
39
+ flushText();
40
+ return rows;
41
+ };
42
+
43
+ const Content = ( { padding = 6, gap = 4, className, children }: DrawerContentProps ) => (
44
+ <VStack
45
+ className={ classnames( 'newspack-drawer__content', className ) }
46
+ spacing={ gap }
47
+ // A custom property, not inline padding, so the stylesheet's seam rules win.
48
+ style={ { '--newspack-drawer-content-padding': `${ padding * 4 }px` } as React.CSSProperties }
49
+ >
50
+ { asRows( children ) }
51
+ </VStack>
52
+ );
53
+
54
+ export default Content;