@wordpress/widget-primitives 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/LICENSE.md +788 -0
- package/README.md +76 -0
- package/build/components/widget-render/index.cjs +31 -0
- package/build/components/widget-render/index.cjs.map +7 -0
- package/build/components/widget-render/widget-render.cjs +50 -0
- package/build/components/widget-render/widget-render.cjs.map +7 -0
- package/build/hooks/index.cjs +31 -0
- package/build/hooks/index.cjs.map +7 -0
- package/build/hooks/use-widget-types.cjs +84 -0
- package/build/hooks/use-widget-types.cjs.map +7 -0
- package/build/index.cjs +34 -0
- package/build/index.cjs.map +7 -0
- package/build/tools/get-lazy-widget-component/get-lazy-widget-component.cjs +50 -0
- package/build/tools/get-lazy-widget-component/get-lazy-widget-component.cjs.map +7 -0
- package/build/tools/get-lazy-widget-component/index.cjs +31 -0
- package/build/tools/get-lazy-widget-component/index.cjs.map +7 -0
- package/build/types.cjs +19 -0
- package/build/types.cjs.map +7 -0
- package/build-module/components/widget-render/index.mjs +6 -0
- package/build-module/components/widget-render/index.mjs.map +7 -0
- package/build-module/components/widget-render/widget-render.mjs +25 -0
- package/build-module/components/widget-render/widget-render.mjs.map +7 -0
- package/build-module/hooks/index.mjs +6 -0
- package/build-module/hooks/index.mjs.map +7 -0
- package/build-module/hooks/use-widget-types.mjs +59 -0
- package/build-module/hooks/use-widget-types.mjs.map +7 -0
- package/build-module/index.mjs +8 -0
- package/build-module/index.mjs.map +7 -0
- package/build-module/tools/get-lazy-widget-component/get-lazy-widget-component.mjs +25 -0
- package/build-module/tools/get-lazy-widget-component/get-lazy-widget-component.mjs.map +7 -0
- package/build-module/tools/get-lazy-widget-component/index.mjs +6 -0
- package/build-module/tools/get-lazy-widget-component/index.mjs.map +7 -0
- package/build-module/types.mjs +1 -0
- package/build-module/types.mjs.map +7 -0
- package/build-types/components/widget-render/index.d.ts +2 -0
- package/build-types/components/widget-render/index.d.ts.map +1 -0
- package/build-types/components/widget-render/stories/index.story.d.ts +19 -0
- package/build-types/components/widget-render/stories/index.story.d.ts.map +1 -0
- package/build-types/components/widget-render/widget-render.d.ts +13 -0
- package/build-types/components/widget-render/widget-render.d.ts.map +1 -0
- package/build-types/hooks/index.d.ts +2 -0
- package/build-types/hooks/index.d.ts.map +1 -0
- package/build-types/hooks/use-widget-types.d.ts +17 -0
- package/build-types/hooks/use-widget-types.d.ts.map +1 -0
- package/build-types/index.d.ts +13 -0
- package/build-types/index.d.ts.map +1 -0
- package/build-types/tools/get-lazy-widget-component/get-lazy-widget-component.d.ts +12 -0
- package/build-types/tools/get-lazy-widget-component/get-lazy-widget-component.d.ts.map +1 -0
- package/build-types/tools/get-lazy-widget-component/index.d.ts +2 -0
- package/build-types/tools/get-lazy-widget-component/index.d.ts.map +1 -0
- package/build-types/types.d.ts +169 -0
- package/build-types/types.d.ts.map +1 -0
- package/package.json +72 -0
- package/src/components/widget-render/index.ts +1 -0
- package/src/components/widget-render/stories/index.story.tsx +356 -0
- package/src/components/widget-render/widget-render.tsx +44 -0
- package/src/hooks/index.ts +1 -0
- package/src/hooks/use-widget-types.ts +90 -0
- package/src/index.ts +21 -0
- package/src/stories/introduction.mdx +14 -0
- package/src/tools/get-lazy-widget-component/get-lazy-widget-component.ts +62 -0
- package/src/tools/get-lazy-widget-component/index.ts +1 -0
- package/src/types.ts +196 -0
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* External dependencies
|
|
3
|
+
*/
|
|
4
|
+
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
5
|
+
import type { ComponentType } from 'react';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* WordPress dependencies
|
|
9
|
+
*/
|
|
10
|
+
// Form controls read these stylesheets, normally enqueued by WordPress.
|
|
11
|
+
// eslint-disable-next-line @wordpress/no-non-module-stylesheet-imports
|
|
12
|
+
import '@wordpress/components/build-style/style.css';
|
|
13
|
+
// eslint-disable-next-line @wordpress/no-non-module-stylesheet-imports
|
|
14
|
+
import '@wordpress/dataviews/build-style/style.css';
|
|
15
|
+
import { DataForm, useFormValidity } from '@wordpress/dataviews';
|
|
16
|
+
import type { Field, Form } from '@wordpress/dataviews';
|
|
17
|
+
import { Suspense, useId, useMemo, useState } from '@wordpress/element';
|
|
18
|
+
import { globe } from '@wordpress/icons';
|
|
19
|
+
import { Card, Icon, Stack } from '@wordpress/ui';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Internal dependencies
|
|
23
|
+
*/
|
|
24
|
+
import { WidgetRender } from '..';
|
|
25
|
+
import type { WidgetRenderProps, WidgetType } from '../../../types';
|
|
26
|
+
|
|
27
|
+
/*
|
|
28
|
+
* Stories run without WordPress, so both halves are declared inline: the
|
|
29
|
+
* type through the `widgetType` prop, the component through
|
|
30
|
+
* `resolveWidgetModule`.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
interface DemoAttributes {
|
|
34
|
+
greeting?: string;
|
|
35
|
+
world?: 'earth' | 'moon' | 'mars' | 'saturn';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const WORLDS: {
|
|
39
|
+
value: NonNullable< DemoAttributes[ 'world' ] >;
|
|
40
|
+
label: string;
|
|
41
|
+
emoji: string;
|
|
42
|
+
background: string;
|
|
43
|
+
}[] = [
|
|
44
|
+
{
|
|
45
|
+
value: 'earth',
|
|
46
|
+
label: 'World',
|
|
47
|
+
emoji: '🌍',
|
|
48
|
+
background: 'var(--wpds-color-background-surface-info-weak)',
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
value: 'moon',
|
|
52
|
+
label: 'Moon',
|
|
53
|
+
emoji: '🌕',
|
|
54
|
+
background: 'var(--wpds-color-background-surface-neutral)',
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
value: 'mars',
|
|
58
|
+
label: 'Mars',
|
|
59
|
+
emoji: '🔴',
|
|
60
|
+
background: 'var(--wpds-color-background-surface-caution-weak)',
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
value: 'saturn',
|
|
64
|
+
label: 'Saturn',
|
|
65
|
+
emoji: '🪐',
|
|
66
|
+
background: 'var(--wpds-color-background-surface-warning-weak)',
|
|
67
|
+
},
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
function DemoWidget( {
|
|
71
|
+
attributes,
|
|
72
|
+
setAttributes,
|
|
73
|
+
}: WidgetRenderProps< DemoAttributes > ) {
|
|
74
|
+
const { greeting = 'Hello', world = 'earth' } = attributes ?? {};
|
|
75
|
+
const index = Math.max(
|
|
76
|
+
0,
|
|
77
|
+
WORLDS.findIndex( ( entry ) => entry.value === world )
|
|
78
|
+
);
|
|
79
|
+
const current = WORLDS[ index ];
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<div
|
|
83
|
+
style={ {
|
|
84
|
+
background: current.background,
|
|
85
|
+
border: '1px solid var(--wpds-color-stroke-surface-neutral)',
|
|
86
|
+
borderRadius: 'var(--wpds-border-radius-md)',
|
|
87
|
+
color: 'var(--wpds-color-foreground-content-neutral)',
|
|
88
|
+
display: 'grid',
|
|
89
|
+
gap: 'var(--wpds-dimension-gap-md)',
|
|
90
|
+
justifyItems: 'center',
|
|
91
|
+
padding: 'var(--wpds-dimension-padding-xl)',
|
|
92
|
+
} }
|
|
93
|
+
>
|
|
94
|
+
<strong style={ { fontSize: '1.5em' } }>
|
|
95
|
+
{ `${ greeting }, ${ current.label }! ${ current.emoji }` }
|
|
96
|
+
</strong>
|
|
97
|
+
|
|
98
|
+
{ setAttributes && (
|
|
99
|
+
<button
|
|
100
|
+
onClick={ () =>
|
|
101
|
+
setAttributes( {
|
|
102
|
+
world: WORLDS[ ( index + 1 ) % WORLDS.length ]
|
|
103
|
+
.value,
|
|
104
|
+
} )
|
|
105
|
+
}
|
|
106
|
+
>
|
|
107
|
+
Next world
|
|
108
|
+
</button>
|
|
109
|
+
) }
|
|
110
|
+
</div>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const demoWidgetType: WidgetType< DemoAttributes > = {
|
|
115
|
+
apiVersion: 1,
|
|
116
|
+
name: 'demo/hello-world',
|
|
117
|
+
title: 'Hello World',
|
|
118
|
+
description: 'Minimal widget that greets worlds near and far.',
|
|
119
|
+
icon: globe,
|
|
120
|
+
renderModule: 'demo/widgets/hello-world/render',
|
|
121
|
+
attributes: [
|
|
122
|
+
{
|
|
123
|
+
id: 'greeting',
|
|
124
|
+
label: 'Greeting',
|
|
125
|
+
type: 'text',
|
|
126
|
+
isValid: { required: true },
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
id: 'world',
|
|
130
|
+
label: 'World',
|
|
131
|
+
type: 'text',
|
|
132
|
+
elements: WORLDS.map( ( { value, label } ) => ( {
|
|
133
|
+
value,
|
|
134
|
+
label,
|
|
135
|
+
} ) ),
|
|
136
|
+
},
|
|
137
|
+
] as Field< DemoAttributes >[],
|
|
138
|
+
example: {
|
|
139
|
+
attributes: { greeting: 'Hello', world: 'mars' },
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
// What `import( widget.renderModule )` resolves to in a real host.
|
|
144
|
+
const resolveDemoModule = async () => ( {
|
|
145
|
+
default: DemoWidget as ComponentType< WidgetRenderProps< unknown > >,
|
|
146
|
+
} );
|
|
147
|
+
|
|
148
|
+
const meta: Meta< typeof WidgetRender > = {
|
|
149
|
+
title: 'Widget Primitives/WidgetRender',
|
|
150
|
+
component: WidgetRender,
|
|
151
|
+
tags: [ 'status-experimental' ],
|
|
152
|
+
parameters: {
|
|
153
|
+
componentStatus: {
|
|
154
|
+
status: 'use-with-caution',
|
|
155
|
+
whereUsed: 'global',
|
|
156
|
+
notes: 'The `@wordpress/widget-primitives` package is under active development: APIs may change without notice. Recommended for development workflows only; not production-ready.',
|
|
157
|
+
},
|
|
158
|
+
docs: {
|
|
159
|
+
description: {
|
|
160
|
+
component: `
|
|
161
|
+
\`WidgetRender\` is the host-agnostic entry point that renders a widget type: it resolves the widget's render component and mounts it with the current attributes.
|
|
162
|
+
|
|
163
|
+
A host provides three things:
|
|
164
|
+
|
|
165
|
+
- \`widgetType\`: the widget's metadata, as declared by its author. In a host it arrives through \`useWidgetTypes()\`.
|
|
166
|
+
- \`resolveWidgetModule\`: how the render component is loaded. Dynamic \`import()\` against an import map, eagerly enqueued script modules, or a custom resolver are all valid strategies.
|
|
167
|
+
- \`setAttributes\` (optional): grants the widget write access to its own attributes. Omit it and the widget renders read-only.
|
|
168
|
+
`,
|
|
169
|
+
},
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
export default meta;
|
|
175
|
+
|
|
176
|
+
function DefaultStory() {
|
|
177
|
+
const [ attributes, setAttributes ] = useState< DemoAttributes >( {
|
|
178
|
+
...demoWidgetType.example?.attributes,
|
|
179
|
+
} );
|
|
180
|
+
|
|
181
|
+
return (
|
|
182
|
+
<Suspense fallback={ null }>
|
|
183
|
+
<WidgetRender< DemoAttributes >
|
|
184
|
+
widgetType={ demoWidgetType }
|
|
185
|
+
attributes={ attributes }
|
|
186
|
+
setAttributes={ ( next ) =>
|
|
187
|
+
setAttributes( ( prev ) => ( { ...prev, ...next } ) )
|
|
188
|
+
}
|
|
189
|
+
resolveWidgetModule={ resolveDemoModule }
|
|
190
|
+
/>
|
|
191
|
+
</Suspense>
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export const Default: StoryObj = {
|
|
196
|
+
render: () => <DefaultStory />,
|
|
197
|
+
parameters: {
|
|
198
|
+
docs: {
|
|
199
|
+
description: {
|
|
200
|
+
story: `
|
|
201
|
+
The minimal contract between a host and a widget:
|
|
202
|
+
|
|
203
|
+
- \`attributes\` flow into the widget as plain data.
|
|
204
|
+
- The widget writes back through \`setAttributes\`. Here, the "Next world" button updates the \`world\` attribute from inside the widget.
|
|
205
|
+
|
|
206
|
+
The primitive resolves the render component with \`lazy()\`, so the surrounding \`Suspense\` boundary, and with it the loading UI, is a host decision.
|
|
207
|
+
`,
|
|
208
|
+
},
|
|
209
|
+
},
|
|
210
|
+
},
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
function WidgetWithSettings() {
|
|
214
|
+
const [ attributes, setAttributes ] = useState< DemoAttributes >( {
|
|
215
|
+
...demoWidgetType.example?.attributes,
|
|
216
|
+
} );
|
|
217
|
+
|
|
218
|
+
const fields = demoWidgetType.attributes as Field< DemoAttributes >[];
|
|
219
|
+
|
|
220
|
+
const form = useMemo< Form >(
|
|
221
|
+
() => ( {
|
|
222
|
+
layout: { type: 'regular', labelPosition: 'top' },
|
|
223
|
+
fields: fields.map( ( field ) => field.id ),
|
|
224
|
+
} ),
|
|
225
|
+
[ fields ]
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
const applyEdits = ( edits: Partial< DemoAttributes > ) =>
|
|
229
|
+
setAttributes( ( prev ) => ( { ...prev, ...edits } ) );
|
|
230
|
+
|
|
231
|
+
const { validity } = useFormValidity( attributes, fields, form );
|
|
232
|
+
|
|
233
|
+
return (
|
|
234
|
+
<div
|
|
235
|
+
style={ {
|
|
236
|
+
alignItems: 'start',
|
|
237
|
+
display: 'grid',
|
|
238
|
+
gap: 'var(--wpds-dimension-gap-xl)',
|
|
239
|
+
gridTemplateColumns: '2fr 1fr',
|
|
240
|
+
} }
|
|
241
|
+
>
|
|
242
|
+
<Suspense fallback={ null }>
|
|
243
|
+
<WidgetRender< DemoAttributes >
|
|
244
|
+
widgetType={ demoWidgetType }
|
|
245
|
+
attributes={ attributes }
|
|
246
|
+
resolveWidgetModule={ resolveDemoModule }
|
|
247
|
+
/>
|
|
248
|
+
</Suspense>
|
|
249
|
+
<aside
|
|
250
|
+
style={ {
|
|
251
|
+
border: '1px solid var(--wpds-color-stroke-surface-neutral)',
|
|
252
|
+
borderRadius: 'var(--wpds-border-radius-md)',
|
|
253
|
+
padding: 'var(--wpds-dimension-padding-lg)',
|
|
254
|
+
} }
|
|
255
|
+
>
|
|
256
|
+
<DataForm< DemoAttributes >
|
|
257
|
+
data={ attributes }
|
|
258
|
+
fields={ fields }
|
|
259
|
+
form={ form }
|
|
260
|
+
validity={ validity }
|
|
261
|
+
onChange={ applyEdits }
|
|
262
|
+
/>
|
|
263
|
+
</aside>
|
|
264
|
+
</div>
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export const WithSettings: StoryObj = {
|
|
269
|
+
render: () => <WidgetWithSettings />,
|
|
270
|
+
parameters: {
|
|
271
|
+
docs: {
|
|
272
|
+
description: {
|
|
273
|
+
story: `
|
|
274
|
+
A widget type declares its settings as a dataviews \`Field[]\` under \`attributes\`. That single declaration is enough for a host to build a settings UI:
|
|
275
|
+
|
|
276
|
+
- The \`DataForm\` on the right is mounted straight from the schema, with no per-widget form wiring.
|
|
277
|
+
- Validation comes from the same source: the \`greeting\` field is marked as required, and \`useFormValidity\` surfaces the result in the form.
|
|
278
|
+
- Edits flow into the rendered widget on the left through the shared attributes state.
|
|
279
|
+
|
|
280
|
+
Any host can derive its settings UI this way, whatever shape it takes.
|
|
281
|
+
`,
|
|
282
|
+
},
|
|
283
|
+
},
|
|
284
|
+
},
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
function WidgetInHostChrome() {
|
|
288
|
+
const [ attributes, setAttributes ] = useState< DemoAttributes >( {
|
|
289
|
+
...demoWidgetType.example?.attributes,
|
|
290
|
+
} );
|
|
291
|
+
|
|
292
|
+
const titleId = useId();
|
|
293
|
+
|
|
294
|
+
return (
|
|
295
|
+
<Card.Root
|
|
296
|
+
render={ <section /> }
|
|
297
|
+
aria-labelledby={ titleId }
|
|
298
|
+
style={ {
|
|
299
|
+
// Striped background to tell the chrome apart from the render.
|
|
300
|
+
background: `repeating-linear-gradient(
|
|
301
|
+
45deg,
|
|
302
|
+
var(--wpds-color-background-surface-neutral),
|
|
303
|
+
var(--wpds-color-background-surface-neutral) 8px,
|
|
304
|
+
var(--wpds-color-background-surface-neutral-weak) 8px,
|
|
305
|
+
var(--wpds-color-background-surface-neutral-weak) 16px
|
|
306
|
+
)`,
|
|
307
|
+
maxWidth: 480,
|
|
308
|
+
} }
|
|
309
|
+
>
|
|
310
|
+
<Card.Header>
|
|
311
|
+
<Stack direction="row" align="center" gap="sm">
|
|
312
|
+
{ demoWidgetType.icon && (
|
|
313
|
+
<span aria-hidden="true">
|
|
314
|
+
<Icon icon={ demoWidgetType.icon } />
|
|
315
|
+
</span>
|
|
316
|
+
) }
|
|
317
|
+
<Card.Title id={ titleId } render={ <h3 /> }>
|
|
318
|
+
{ demoWidgetType.title }
|
|
319
|
+
</Card.Title>
|
|
320
|
+
</Stack>
|
|
321
|
+
</Card.Header>
|
|
322
|
+
<Card.Content>
|
|
323
|
+
<Suspense fallback={ null }>
|
|
324
|
+
<WidgetRender< DemoAttributes >
|
|
325
|
+
widgetType={ demoWidgetType }
|
|
326
|
+
attributes={ attributes }
|
|
327
|
+
setAttributes={ ( next ) =>
|
|
328
|
+
setAttributes( ( prev ) => ( {
|
|
329
|
+
...prev,
|
|
330
|
+
...next,
|
|
331
|
+
} ) )
|
|
332
|
+
}
|
|
333
|
+
resolveWidgetModule={ resolveDemoModule }
|
|
334
|
+
/>
|
|
335
|
+
</Suspense>
|
|
336
|
+
</Card.Content>
|
|
337
|
+
</Card.Root>
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
export const WithHostChrome: StoryObj = {
|
|
342
|
+
render: () => <WidgetInHostChrome />,
|
|
343
|
+
parameters: {
|
|
344
|
+
docs: {
|
|
345
|
+
description: {
|
|
346
|
+
story: `
|
|
347
|
+
Chrome belongs to the host: the widget describes itself through metadata, and each host decides how (and whether) to frame it.
|
|
348
|
+
|
|
349
|
+
In this story the chrome is a \`Card\`: its header reads the type's metadata (\`icon\`, \`title\`) and the card body frames the widget render.
|
|
350
|
+
|
|
351
|
+
The diagonal stripes mark the chrome's area; the solid panel inside is the widget render. The widget renders no header of its own; another host could place the same metadata elsewhere, or skip it.
|
|
352
|
+
`,
|
|
353
|
+
},
|
|
354
|
+
},
|
|
355
|
+
},
|
|
356
|
+
};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* External dependencies
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Internal dependencies
|
|
7
|
+
*/
|
|
8
|
+
import { getLazyWidgetComponent } from '../../tools/get-lazy-widget-component';
|
|
9
|
+
import type { ResolveWidgetModule, WidgetType } from '../../types';
|
|
10
|
+
|
|
11
|
+
interface WidgetRenderProps< Item = unknown > {
|
|
12
|
+
widgetType: WidgetType< Item >;
|
|
13
|
+
attributes?: Item;
|
|
14
|
+
setAttributes?: ( next: Partial< Item > ) => void;
|
|
15
|
+
resolveWidgetModule: ResolveWidgetModule;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/*
|
|
19
|
+
* Resolves a widget type's `renderModule` via `resolveWidgetModule` and
|
|
20
|
+
* mounts the resulting component with the `attributes`/`setAttributes`
|
|
21
|
+
* render contract.
|
|
22
|
+
*/
|
|
23
|
+
export function WidgetRender< Item = unknown >( {
|
|
24
|
+
widgetType,
|
|
25
|
+
attributes,
|
|
26
|
+
setAttributes,
|
|
27
|
+
resolveWidgetModule,
|
|
28
|
+
}: WidgetRenderProps< Item > ) {
|
|
29
|
+
const WidgetComponent = getLazyWidgetComponent(
|
|
30
|
+
widgetType.renderModule,
|
|
31
|
+
resolveWidgetModule
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<>
|
|
36
|
+
{ /* Cached `lazy()` keyed by renderModule; identity is stable across renders. */ }
|
|
37
|
+
{ /* eslint-disable-next-line react-hooks/static-components */ }
|
|
38
|
+
<WidgetComponent
|
|
39
|
+
attributes={ attributes }
|
|
40
|
+
setAttributes={ setAttributes }
|
|
41
|
+
/>
|
|
42
|
+
</>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { useWidgetTypes } from './use-widget-types';
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WordPress dependencies
|
|
3
|
+
*/
|
|
4
|
+
import { useEffect, useState } from '@wordpress/element';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Internal dependencies
|
|
8
|
+
*/
|
|
9
|
+
import type { WidgetModuleRecord, WidgetName, WidgetType } from '../types';
|
|
10
|
+
|
|
11
|
+
/* `true` while records or their metadata imports are still resolving; hosts
|
|
12
|
+
must not treat a widget instance as missing until it is `false`. */
|
|
13
|
+
type UseWidgetTypesResult = readonly [ WidgetType[], boolean ];
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Resolves widget types from host-supplied records.
|
|
17
|
+
*
|
|
18
|
+
* For each record it dynamically imports `widget_module` and merges the
|
|
19
|
+
* module's default export with the runtime fields (`name`, `renderModule`).
|
|
20
|
+
* Pass `null`/`undefined` while records are still loading.
|
|
21
|
+
*
|
|
22
|
+
* @param records Host-supplied records, or `null`/`undefined` while loading.
|
|
23
|
+
*/
|
|
24
|
+
export function useWidgetTypes(
|
|
25
|
+
records: WidgetModuleRecord[] | null | undefined
|
|
26
|
+
): UseWidgetTypesResult {
|
|
27
|
+
const [ widgetTypes, setWidgetTypes ] = useState< WidgetType[] >( [] );
|
|
28
|
+
const [ isResolvingWidgetTypes, setIsResolvingWidgetTypes ] =
|
|
29
|
+
useState( true );
|
|
30
|
+
|
|
31
|
+
useEffect( () => {
|
|
32
|
+
if ( records === null || records === undefined ) {
|
|
33
|
+
setIsResolvingWidgetTypes( true );
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if ( records.length === 0 ) {
|
|
38
|
+
setWidgetTypes( [] );
|
|
39
|
+
setIsResolvingWidgetTypes( false );
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
let cancelled = false;
|
|
44
|
+
setIsResolvingWidgetTypes( true );
|
|
45
|
+
|
|
46
|
+
Promise.all(
|
|
47
|
+
records.map( async ( record ) => {
|
|
48
|
+
if ( ! record.widget_module ) {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
const module = await import(
|
|
54
|
+
/* webpackIgnore: true */ record.widget_module
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
if ( ! module?.default ) {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
...( module.default as Partial< WidgetType > ),
|
|
63
|
+
name: record.name as WidgetName,
|
|
64
|
+
renderModule: record.render_module ?? '',
|
|
65
|
+
...( record.presentation
|
|
66
|
+
? { presentation: record.presentation }
|
|
67
|
+
: {} ),
|
|
68
|
+
} as WidgetType;
|
|
69
|
+
} catch {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
} )
|
|
73
|
+
).then( ( results ) => {
|
|
74
|
+
if ( cancelled ) {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
setWidgetTypes(
|
|
79
|
+
results.filter( ( t ): t is WidgetType => t !== null )
|
|
80
|
+
);
|
|
81
|
+
setIsResolvingWidgetTypes( false );
|
|
82
|
+
} );
|
|
83
|
+
|
|
84
|
+
return () => {
|
|
85
|
+
cancelled = true;
|
|
86
|
+
};
|
|
87
|
+
}, [ records ] );
|
|
88
|
+
|
|
89
|
+
return [ widgetTypes, isResolvingWidgetTypes ];
|
|
90
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Components
|
|
3
|
+
*/
|
|
4
|
+
export { WidgetRender } from './components/widget-render';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Hooks
|
|
8
|
+
*/
|
|
9
|
+
export { useWidgetTypes } from './hooks';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Types
|
|
13
|
+
*/
|
|
14
|
+
export type {
|
|
15
|
+
WidgetName,
|
|
16
|
+
WidgetIcon,
|
|
17
|
+
WidgetType,
|
|
18
|
+
WidgetRenderProps,
|
|
19
|
+
ResolveWidgetModule,
|
|
20
|
+
WidgetModuleRecord,
|
|
21
|
+
} from './types';
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { Meta, Markdown } from '@storybook/addon-docs/blocks';
|
|
2
|
+
import ArchitectureDoc from '../../../../docs/explanations/architecture/dashboard-widgets.md?raw';
|
|
3
|
+
import pipelineDiagram from '../../../../docs/explanations/architecture/assets/dashboard-widgets-pipeline.svg';
|
|
4
|
+
|
|
5
|
+
{/* Use the locally served diagram so Storybook renders it offline. */}
|
|
6
|
+
|
|
7
|
+
<Meta title="Widget Primitives/Introduction" />
|
|
8
|
+
|
|
9
|
+
<Markdown>
|
|
10
|
+
{ ArchitectureDoc.replace(
|
|
11
|
+
'https://raw.githubusercontent.com/WordPress/gutenberg/HEAD/docs/explanations/architecture/assets/dashboard-widgets-pipeline.svg',
|
|
12
|
+
pipelineDiagram
|
|
13
|
+
) }
|
|
14
|
+
</Markdown>
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* External dependencies
|
|
3
|
+
*/
|
|
4
|
+
import type { ComponentType } from 'react';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* WordPress dependencies
|
|
8
|
+
*/
|
|
9
|
+
import { lazy } from '@wordpress/element';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Internal dependencies
|
|
13
|
+
*/
|
|
14
|
+
import type {
|
|
15
|
+
ResolveWidgetModule,
|
|
16
|
+
WidgetModule,
|
|
17
|
+
WidgetRenderProps,
|
|
18
|
+
} from '../../types';
|
|
19
|
+
|
|
20
|
+
type LazyWidgetComponent = ComponentType< WidgetRenderProps< unknown > >;
|
|
21
|
+
|
|
22
|
+
function isValidWidgetModule( module: unknown ): module is WidgetModule {
|
|
23
|
+
return (
|
|
24
|
+
typeof module === 'object' &&
|
|
25
|
+
module !== null &&
|
|
26
|
+
'default' in module &&
|
|
27
|
+
typeof ( module as { default: unknown } ).default === 'function'
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/*
|
|
32
|
+
* Cache keyed by `renderModule`. The lazy component must keep a stable
|
|
33
|
+
* identity across renders; rebuilding it inline (e.g. via `useMemo`) resets
|
|
34
|
+
* the Suspense boundary and the resolved module.
|
|
35
|
+
*/
|
|
36
|
+
const componentCache = new Map< string, LazyWidgetComponent >();
|
|
37
|
+
|
|
38
|
+
/*
|
|
39
|
+
* Resolve a widget render module to a `lazy()` React component, cached by
|
|
40
|
+
* `renderModule` id so repeated calls return the same instance.
|
|
41
|
+
*/
|
|
42
|
+
export function getLazyWidgetComponent(
|
|
43
|
+
renderModule: string,
|
|
44
|
+
resolveWidgetModule: ResolveWidgetModule
|
|
45
|
+
): LazyWidgetComponent {
|
|
46
|
+
const cached = componentCache.get( renderModule );
|
|
47
|
+
if ( cached ) {
|
|
48
|
+
return cached;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const lazyComponent = lazy< LazyWidgetComponent >( async () => {
|
|
52
|
+
const module: unknown = await resolveWidgetModule( renderModule );
|
|
53
|
+
if ( ! isValidWidgetModule( module ) ) {
|
|
54
|
+
throw new Error( `Invalid widget module: ${ renderModule }` );
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return module;
|
|
58
|
+
} );
|
|
59
|
+
|
|
60
|
+
componentCache.set( renderModule, lazyComponent );
|
|
61
|
+
return lazyComponent;
|
|
62
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { getLazyWidgetComponent } from './get-lazy-widget-component';
|