@prongsjs/ui 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/tooling/components.js +10 -5
- package/dist/tooling/spec.js +104 -11
- package/dist/vocabulary.js +2 -2
- package/package.json +2 -2
|
@@ -25,7 +25,7 @@ export const COMPONENT_DOCS = {
|
|
|
25
25
|
{ name: 'intent', type: 'TextIntent', description: 'What this text is for: `heading.*`, `text.*` or `feedback.*`.', required: true },
|
|
26
26
|
{
|
|
27
27
|
name: 'as',
|
|
28
|
-
type: "'h1'…'h6' | 'p' | 'span' | 'div' | 'label'",
|
|
28
|
+
type: "'h1'…'h6' | 'p' | 'span' | 'div' | 'label' | 'dt' | 'dd'",
|
|
29
29
|
description: 'Changes the element for document structure only, like `as="label"` for a field label or `as="h2"` for a dialog title. The intent still decides the look.',
|
|
30
30
|
},
|
|
31
31
|
{ name: 'htmlFor', type: 'string', description: 'With `as="label"`: the `id` of the field it labels.' },
|
|
@@ -50,6 +50,8 @@ export const COMPONENT_DOCS = {
|
|
|
50
50
|
{ name: 'label', type: 'string', description: 'The accessible name. Required when there is no visible text (an icon alone).' },
|
|
51
51
|
{ name: 'disabled', type: 'boolean', description: 'Turns it off. A disabled link loses its `href`.' },
|
|
52
52
|
{ name: 'type', type: "'button' | 'submit' | 'reset'", description: 'Buttons are `type="button"` unless the intent says otherwise (`action.submit` is `submit`).' },
|
|
53
|
+
{ name: 'verb', type: 'string', description: 'The verb this action performs, for analytics: `verb="duplicate"` sets `data-verb`. The intent stays the same.' },
|
|
54
|
+
{ name: 'tone', type: "'danger'", description: 'For an action that is destructive or hard to undo, like leaving a workspace. Sets `data-tone`; the intent styles it.' },
|
|
53
55
|
VARS,
|
|
54
56
|
UNSAFE,
|
|
55
57
|
],
|
|
@@ -64,11 +66,13 @@ export const COMPONENT_DOCS = {
|
|
|
64
66
|
summary: 'What the user types or picks in a form. The intent decides the element (`<input>`, `<textarea>`, `<select>`) and sets `type`, `inputMode` and `autoComplete`.',
|
|
65
67
|
layoutProps: true,
|
|
66
68
|
props: [
|
|
67
|
-
{ name: 'intent', type: 'FieldIntent', description: 'What is being entered: `field.email`, `field.password`, `field.
|
|
69
|
+
{ name: 'intent', type: 'FieldIntent', description: 'What is being entered: `field.email`, `field.password`, `field.date`...', required: true },
|
|
68
70
|
{ name: 'label', type: 'ReactNode', description: 'The label. With it, Field renders the whole `layout.field`: label, field, hint and error, connected for screen readers.' },
|
|
69
71
|
{ name: 'hint', type: 'ReactNode', description: 'Help under the field (`text.caption`). Needs `label`.' },
|
|
70
72
|
{ name: 'error', type: 'ReactNode', description: 'What is wrong with the value (`feedback.error`). Marks the field invalid. Needs `label`.' },
|
|
71
73
|
{ name: 'rows', type: 'number', description: 'Height of a multiline field, in lines.' },
|
|
74
|
+
{ name: 'revealed', type: 'boolean', description: 'For `field.password`: true while the password is shown as text.' },
|
|
75
|
+
{ name: 'type', type: "'date' | 'time' | 'datetime-local'", description: 'For `field.date`: a time, or a date and time, instead of a date.' },
|
|
72
76
|
{ name: 'children', type: 'ReactNode', description: 'For select fields: the `<option>`s.' },
|
|
73
77
|
{ name: 'ref', type: 'Ref<HTMLInputElement>', description: 'Reaches the underlying element.' },
|
|
74
78
|
UNSAFE,
|
|
@@ -122,7 +126,7 @@ export const COMPONENT_DOCS = {
|
|
|
122
126
|
summary: 'Modal interruptions on the native `<dialog>`: focus stays inside, Escape closes it and the page behind is inert.',
|
|
123
127
|
layoutProps: false,
|
|
124
128
|
props: [
|
|
125
|
-
{ name: 'intent', type: 'DialogIntent', description: '`dialog.task`, `dialog.confirm
|
|
129
|
+
{ name: 'intent', type: 'DialogIntent', description: '`dialog.task`, `dialog.confirm`, `dialog.sheet`...', required: true },
|
|
126
130
|
{ name: 'open', type: 'boolean', description: 'Whether it is showing. You keep it in state.', required: true },
|
|
127
131
|
{ name: 'onClose', type: '() => void', description: 'Called on Escape, a click outside, or when it closes itself.', required: true },
|
|
128
132
|
{ name: 'label', type: 'string', description: 'The accessible name: usually the heading text.', required: true },
|
|
@@ -140,8 +144,9 @@ export const COMPONENT_DOCS = {
|
|
|
140
144
|
{ name: 'label', type: 'string', description: 'The accessible name: what it holds, like "Article actions".', required: true },
|
|
141
145
|
{ name: 'children', type: 'ReactNode', description: 'The content: `<Action>`s for a menu, choices for a picker, text for detail.' },
|
|
142
146
|
{ name: 'options', type: '{ value, label?, disabled? }[]', description: 'A picker’s choices as data, for lists from a database or a CMS. They render as a listbox (`role="option"`, `aria-selected`), announced like a native select.' },
|
|
143
|
-
{ name: 'value', type: 'string | string[]', description: 'A picker’s choice: one value, or several
|
|
144
|
-
{ name: 'onChange', type: '(value) => void', description: 'Called with the new choice, or with every picked value
|
|
147
|
+
{ name: 'value', type: 'string | string[]', description: 'A picker’s choice: one value, or several with `multiple`.' },
|
|
148
|
+
{ name: 'onChange', type: '(value) => void', description: 'Called with the new choice, or with every picked value with `multiple`.' },
|
|
149
|
+
{ name: 'multiple', type: 'boolean', description: 'For `popover.picker`: any number of choices can be picked. It stays open while picking.' },
|
|
145
150
|
{ name: 'keepOpen', type: 'boolean', description: 'Stay open when something inside is pressed. Pickers that pick many stay open anyway.' },
|
|
146
151
|
UNSAFE,
|
|
147
152
|
],
|
package/dist/tooling/spec.js
CHANGED
|
@@ -30,6 +30,7 @@ function arrangementOf(classes) {
|
|
|
30
30
|
const width = has('mx-auto') ? list.find((name) => name.startsWith('max-w-')) : undefined;
|
|
31
31
|
return [flow, gap, width && `centred ${width}`].filter(Boolean).join(', ');
|
|
32
32
|
}
|
|
33
|
+
const applies = (registry, g) => g.ids.every((id) => registry.has(id)) && !(g.without ?? []).some((id) => registry.has(id));
|
|
33
34
|
const NESTING = [
|
|
34
35
|
{ ids: ['layout.form', 'layout.field', 'layout.actions'], text: '`layout.form` holds `layout.field`s, then one `layout.actions` with its buttons.' },
|
|
35
36
|
{
|
|
@@ -42,7 +43,15 @@ const NESTING = [
|
|
|
42
43
|
text: '`layout.table` is the `<table>`. Inside it write plain `<thead>`, `<tbody>`, `<tr>`, `<th scope="col">` and `<td>` with no classes; put `<Text>` and `<Action>` intents in the cells. When it has more columns than fit on a phone, wrap it in `<Layout intent="layout.scroller" tabIndex={0} aria-label="...">`.',
|
|
43
44
|
},
|
|
44
45
|
{ ids: ['layout.shell', 'layout.aside', 'layout.page'], text: '`layout.shell` holds one `layout.aside` (the sidebar) and the `layout.page`.' },
|
|
45
|
-
{ ids: ['layout.
|
|
46
|
+
{ ids: ['layout.group', 'text.meta'], text: 'A line of `text.meta` facts (time, author, counts) about one item sits in a `layout.group` with `flex-row`.' },
|
|
47
|
+
{
|
|
48
|
+
ids: ['layout.details', 'text.label'],
|
|
49
|
+
text: '`layout.details` (`<dl>`) holds one plain `<div>` per pair: a `<Text intent="text.label" as="dt">` and the value `as="dd"`.',
|
|
50
|
+
},
|
|
51
|
+
{ ids: ['layout.intro', 'layout.section'], text: '`layout.intro` holds a section’s heading and lead, first in its `layout.section`, above the content it introduces.' },
|
|
52
|
+
{ ids: ['layout.split'], text: '`layout.split` holds exactly two children, side by side on wider screens and stacked on phones.' },
|
|
53
|
+
{ ids: ['layout.toast'], text: '`layout.toast` holds one `feedback.*` line and at most one `<Action>`, like Undo.' },
|
|
54
|
+
{ ids: ['layout.empty'], text: '`layout.empty` holds an icon or image, a `heading.item`, a `text.caption` and the first action to take.' },
|
|
46
55
|
{ ids: ['layout.toolbar'], text: '`layout.toolbar` holds the controls for the content below it: filters, views, search, a refresh.' },
|
|
47
56
|
{ ids: ['layout.notice', 'action.dismiss'], text: '`layout.notice` holds one `feedback.*` message and an `action.dismiss`.' },
|
|
48
57
|
{ ids: ['layout.disclosure', 'text.summary'], text: '`layout.disclosure` (`<details>`) starts with one `text.summary` line, then the content it opens.' },
|
|
@@ -61,18 +70,51 @@ const EXAMPLES = [
|
|
|
61
70
|
<Action intent="action.cancel" onClick={cancel}>Cancel</Action>
|
|
62
71
|
<Action intent="action.save" type="submit">Save</Action>
|
|
63
72
|
</Layout>
|
|
73
|
+
</Layout>`,
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
title: 'A form with a label, a hint and an error',
|
|
77
|
+
ids: ['layout.form', 'layout.field', 'text.label', 'field.text', 'feedback.error', 'field.email', 'text.caption', 'layout.actions', 'action.cancel', 'action.submit'],
|
|
78
|
+
without: ['action.save'],
|
|
79
|
+
text: `<Layout intent="layout.form" onSubmit={save}>
|
|
80
|
+
<Field intent="field.text" label="Display name" error={nameError} value={name} onChange={(e) => setName(e.target.value)} />
|
|
81
|
+
<Field intent="field.email" label="Email" hint="Only used for signing in." />
|
|
82
|
+
<Layout intent="layout.actions">
|
|
83
|
+
<Action intent="action.cancel" onClick={cancel}>Cancel</Action>
|
|
84
|
+
<Action intent="action.submit" verb="save">Save</Action>
|
|
85
|
+
</Layout>
|
|
64
86
|
</Layout>`,
|
|
65
87
|
},
|
|
66
88
|
{
|
|
67
89
|
title: 'A list of items with facts and a row action',
|
|
68
|
-
ids: ['layout.list', 'layout.item', 'media.person', 'layout.group', 'heading.item', '
|
|
90
|
+
ids: ['layout.list', 'layout.item', 'media.person', 'layout.group', 'heading.item', 'text.meta', 'action.command'],
|
|
91
|
+
without: ['action.remove'],
|
|
69
92
|
text: `<Layout intent="layout.list">
|
|
70
93
|
{members.map((member) => (
|
|
71
94
|
<Layout key={member.id} intent="layout.item" className="flex-row items-center">
|
|
72
95
|
<Media intent="media.person" src={member.avatarUrl} alt={member.name} />
|
|
73
96
|
<Layout intent="layout.group" className="flex-1">
|
|
74
97
|
<Text intent="heading.item">{member.name}</Text>
|
|
75
|
-
<Layout intent="layout.
|
|
98
|
+
<Layout intent="layout.group" className="flex-row flex-wrap">
|
|
99
|
+
<Text intent="text.meta">{member.role}</Text>
|
|
100
|
+
<Text intent="text.meta">Joined {member.joined}</Text>
|
|
101
|
+
</Layout>
|
|
102
|
+
</Layout>
|
|
103
|
+
<Action intent="action.command" verb="remove" tone="danger" onClick={() => remove(member.id)}>Remove</Action>
|
|
104
|
+
</Layout>
|
|
105
|
+
))}
|
|
106
|
+
</Layout>`,
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
title: 'A list of items with facts and a row action',
|
|
110
|
+
ids: ['layout.list', 'layout.item', 'media.person', 'layout.group', 'heading.item', 'text.meta', 'action.remove'],
|
|
111
|
+
text: `<Layout intent="layout.list">
|
|
112
|
+
{members.map((member) => (
|
|
113
|
+
<Layout key={member.id} intent="layout.item" className="flex-row items-center">
|
|
114
|
+
<Media intent="media.person" src={member.avatarUrl} alt={member.name} />
|
|
115
|
+
<Layout intent="layout.group" className="flex-1">
|
|
116
|
+
<Text intent="heading.item">{member.name}</Text>
|
|
117
|
+
<Layout intent="layout.group" className="flex-row flex-wrap">
|
|
76
118
|
<Text intent="text.meta">{member.role}</Text>
|
|
77
119
|
<Text intent="text.meta">Joined {member.joined}</Text>
|
|
78
120
|
</Layout>
|
|
@@ -108,6 +150,57 @@ const EXAMPLES = [
|
|
|
108
150
|
</Layout>
|
|
109
151
|
</Layout>
|
|
110
152
|
</Dialog>`,
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
title: 'A section with an introduction and a grid',
|
|
156
|
+
ids: ['layout.section', 'layout.intro', 'heading.section', 'text.lead', 'layout.collection', 'layout.panel'],
|
|
157
|
+
text: `<Layout intent="layout.section">
|
|
158
|
+
<Layout intent="layout.intro">
|
|
159
|
+
<Text intent="heading.section">Plans</Text>
|
|
160
|
+
<Text intent="text.lead">Start free and upgrade when your team grows.</Text>
|
|
161
|
+
</Layout>
|
|
162
|
+
<Layout intent="layout.collection">
|
|
163
|
+
{plans.map((plan) => (
|
|
164
|
+
<Layout key={plan.id} intent="layout.panel">…</Layout>
|
|
165
|
+
))}
|
|
166
|
+
</Layout>
|
|
167
|
+
</Layout>`,
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
title: 'Facts about one record, beside its image',
|
|
171
|
+
ids: ['layout.split', 'media.image', 'layout.details', 'text.label', 'text.body'],
|
|
172
|
+
text: `<Layout intent="layout.split">
|
|
173
|
+
<Media intent="media.image" src={order.image} alt={order.name} />
|
|
174
|
+
<Layout intent="layout.details">
|
|
175
|
+
<div>
|
|
176
|
+
<Text intent="text.label" as="dt">Ordered</Text>
|
|
177
|
+
<Text intent="text.body" as="dd">{order.date}</Text>
|
|
178
|
+
</div>
|
|
179
|
+
<div>
|
|
180
|
+
<Text intent="text.label" as="dt">Total</Text>
|
|
181
|
+
<Text intent="text.body" as="dd">{order.total}</Text>
|
|
182
|
+
</div>
|
|
183
|
+
</Layout>
|
|
184
|
+
</Layout>`,
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
title: 'Loading, empty and a saved message',
|
|
188
|
+
ids: ['layout.loading', 'layout.empty', 'heading.item', 'text.caption', 'action.start', 'layout.toast', 'feedback.success', 'action.command'],
|
|
189
|
+
without: ['action.create'],
|
|
190
|
+
text: `{projects === undefined && <Layout intent="layout.loading" className="h-24" aria-busy="true" />}
|
|
191
|
+
{projects?.length === 0 && (
|
|
192
|
+
<Layout intent="layout.empty">
|
|
193
|
+
<Text intent="heading.item">No projects yet</Text>
|
|
194
|
+
<Text intent="text.caption">A project holds your pages and who can edit them.</Text>
|
|
195
|
+
<Action intent="action.start" verb="create" href="/projects/new">Create project</Action>
|
|
196
|
+
</Layout>
|
|
197
|
+
)}
|
|
198
|
+
{saved && (
|
|
199
|
+
<Layout intent="layout.toast">
|
|
200
|
+
<Text intent="feedback.success">Your changes were saved.</Text>
|
|
201
|
+
<Action intent="action.command" verb="undo" onClick={undo}>Undo</Action>
|
|
202
|
+
</Layout>
|
|
203
|
+
)}`,
|
|
111
204
|
},
|
|
112
205
|
{
|
|
113
206
|
title: 'Loading, empty and a dismissible notice',
|
|
@@ -150,11 +243,11 @@ function componentProps() {
|
|
|
150
243
|
}
|
|
151
244
|
/** The examples whose intents all exist, for tests that check them against a registry. */
|
|
152
245
|
export function specExamples(registry) {
|
|
153
|
-
return EXAMPLES.filter((e) =>
|
|
246
|
+
return EXAMPLES.filter((e) => applies(registry, e)).map((e) => ({ title: e.title, code: e.text }));
|
|
154
247
|
}
|
|
155
248
|
export function uiSpecSection(registry, context) {
|
|
156
249
|
const from = context.componentsImport ?? './components/prongs';
|
|
157
|
-
const has = (g) =>
|
|
250
|
+
const has = (g) => applies(registry, g);
|
|
158
251
|
const out = [
|
|
159
252
|
'## UI rules',
|
|
160
253
|
'',
|
|
@@ -260,8 +353,8 @@ export const UI_RULE_DOCS = [
|
|
|
260
353
|
profile: 'ui',
|
|
261
354
|
severity: 'error',
|
|
262
355
|
summary: 'An intent rendered by the wrong component, like an action on `<Text>`.',
|
|
263
|
-
bad: '<Text intent="action.
|
|
264
|
-
good: '<Action intent="action.
|
|
356
|
+
bad: '<Text intent="action.submit">Save</Text>',
|
|
357
|
+
good: '<Action intent="action.submit">Save</Action>',
|
|
265
358
|
},
|
|
266
359
|
{
|
|
267
360
|
id: 'leaf-classname',
|
|
@@ -276,7 +369,7 @@ export const UI_RULE_DOCS = [
|
|
|
276
369
|
profile: 'ui',
|
|
277
370
|
severity: 'error',
|
|
278
371
|
summary: '`style` on a component that takes no style. An inline style overrides the intent just like a className.',
|
|
279
|
-
bad: '<Action intent="action.
|
|
372
|
+
bad: '<Action intent="action.submit" style={{ color: \'red\' }}>Save</Action>',
|
|
280
373
|
good: '<Action intent="action.delete">Delete</Action>',
|
|
281
374
|
},
|
|
282
375
|
{
|
|
@@ -314,8 +407,8 @@ export const UI_RULE_DOCS = [
|
|
|
314
407
|
profile: 'ui',
|
|
315
408
|
severity: 'warning',
|
|
316
409
|
summary: 'An action whose handler, label or text points to a different intent, through the intents’ keywords.',
|
|
317
|
-
bad: '<Action intent="action.
|
|
318
|
-
good: '<Action intent="action.
|
|
410
|
+
bad: '<Action intent="action.delete" onClick={archiveProject}>Archive</Action>',
|
|
411
|
+
good: '<Action intent="action.command" verb="archive" onClick={archiveProject}>Archive</Action>',
|
|
319
412
|
},
|
|
320
413
|
{
|
|
321
414
|
id: 'raw-look',
|
|
@@ -355,6 +448,6 @@ export const UI_RULE_DOCS = [
|
|
|
355
448
|
severity: 'info',
|
|
356
449
|
summary: 'An intent computed at runtime, so it could not be checked. A ternary of literals can be.',
|
|
357
450
|
bad: '<Action intent={intentFor(state)}>…</Action>',
|
|
358
|
-
good:
|
|
451
|
+
good: '<Action intent="action.toggle" pressed={following}>Follow</Action>',
|
|
359
452
|
},
|
|
360
453
|
];
|
package/dist/vocabulary.js
CHANGED
|
@@ -19,10 +19,10 @@ export const COMPONENT_DOMAINS = {
|
|
|
19
19
|
export const COMPONENT_NAMES = Object.keys(COMPONENT_DOMAINS);
|
|
20
20
|
/** Elements an intent may choose through `binding.element`, per component. */
|
|
21
21
|
export const ALLOWED_ELEMENTS = {
|
|
22
|
-
Text: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'span', 'div', 'label', 'strong', 'em', 'small', 'blockquote', 'code', 'pre', 'summary'],
|
|
22
|
+
Text: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'span', 'div', 'label', 'strong', 'em', 'small', 'blockquote', 'code', 'pre', 'summary', 'dt', 'dd'],
|
|
23
23
|
Action: [],
|
|
24
24
|
Field: ['input', 'textarea', 'select'],
|
|
25
|
-
Layout: ['div', 'main', 'section', 'article', 'aside', 'header', 'footer', 'nav', 'form', 'fieldset', 'ul', 'ol', 'li', 'hr', 'details', 'table', 'figure'],
|
|
25
|
+
Layout: ['div', 'main', 'section', 'article', 'aside', 'header', 'footer', 'nav', 'form', 'fieldset', 'ul', 'ol', 'li', 'dl', 'hr', 'details', 'table', 'figure'],
|
|
26
26
|
/** Images by default; an intent can make it a frame that shows another page. */
|
|
27
27
|
Media: ['iframe'],
|
|
28
28
|
Dialog: [],
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@prongsjs/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "The Prongs UI profile: intents that resolve to Tailwind classes, elements and ARIA roles, plus the tooling that checks how they are used.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"intents",
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
"prepublishOnly": "npm run build"
|
|
53
53
|
},
|
|
54
54
|
"dependencies": {
|
|
55
|
-
"@prongsjs/core": "0.
|
|
55
|
+
"@prongsjs/core": "0.3.0"
|
|
56
56
|
},
|
|
57
57
|
"peerDependencies": {
|
|
58
58
|
"tailwind-merge": "^2.5.0 || ^3.0.0",
|