@estiva-app/ui 0.62.1 → 0.64.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.
Files changed (54) hide show
  1. package/dist/CodeBlockColours.d.ts +11 -0
  2. package/dist/CodeBlockColours.d.ts.map +1 -0
  3. package/dist/ComposerTriggers.d.ts +8 -2
  4. package/dist/ComposerTriggers.d.ts.map +1 -1
  5. package/dist/KindIcon.d.ts +30 -0
  6. package/dist/KindIcon.d.ts.map +1 -0
  7. package/dist/Link.d.ts +1 -1
  8. package/dist/PastedImages.d.ts +33 -0
  9. package/dist/PastedImages.d.ts.map +1 -0
  10. package/dist/RichText.d.ts +2 -1
  11. package/dist/RichText.d.ts.map +1 -1
  12. package/dist/codeColours.d.ts +29 -0
  13. package/dist/codeColours.d.ts.map +1 -0
  14. package/dist/editor.d.ts +2 -0
  15. package/dist/editor.d.ts.map +1 -1
  16. package/dist/editor.js +86 -13
  17. package/dist/editor.js.map +4 -4
  18. package/dist/eslint/index.js +1 -0
  19. package/dist/eslint/index.js.map +2 -2
  20. package/dist/eslint/no-restyled-part.d.ts.map +1 -1
  21. package/dist/index.d.ts +1 -0
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +291 -237
  24. package/dist/index.js.map +4 -4
  25. package/package.json +8 -2
  26. package/registry.json +179 -16
  27. package/src/CodeBlockColours.mdx +48 -0
  28. package/src/CodeBlockColours.stories.tsx +37 -0
  29. package/src/CodeBlockColours.ts +14 -0
  30. package/src/ComposerTriggers.mdx +6 -4
  31. package/src/ComposerTriggers.test.tsx +30 -0
  32. package/src/ComposerTriggers.tsx +9 -3
  33. package/src/KindIcon.mdx +58 -0
  34. package/src/KindIcon.stories.tsx +64 -0
  35. package/src/KindIcon.tsx +64 -0
  36. package/src/Link.mdx +1 -1
  37. package/src/Link.stories.tsx +1 -1
  38. package/src/Link.test.tsx +2 -2
  39. package/src/Link.tsx +2 -2
  40. package/src/PastedImages.mdx +61 -0
  41. package/src/PastedImages.stories.tsx +53 -0
  42. package/src/PastedImages.test.ts +83 -0
  43. package/src/PastedImages.ts +105 -0
  44. package/src/RichText.stories.tsx +24 -0
  45. package/src/RichText.tsx +31 -17
  46. package/src/codeColours.ts +33 -0
  47. package/src/editor.ts +2 -0
  48. package/src/eslint/no-restyled-part.ts +1 -0
  49. package/src/index.ts +1 -0
  50. package/src/pages.test.ts +5 -1
  51. package/src/registry/registry.test.ts +7 -7
  52. package/stories/Choosing.mdx +3 -0
  53. package/tailwind-preset.js +8 -0
  54. package/tokens.css +50 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@estiva-app/ui",
3
- "version": "0.62.1",
3
+ "version": "0.64.0",
4
4
  "description": "Estiva's design tokens (the contract) and a small set of primitives (a convenience) for every Estiva app.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -91,7 +91,8 @@
91
91
  "@tiptap/extension-mention": "^3.22.1",
92
92
  "@tiptap/pm": "^3.22.1",
93
93
  "@tiptap/react": "^3.22.1",
94
- "@tiptap/suggestion": "^3.22.1"
94
+ "@tiptap/suggestion": "^3.22.1",
95
+ "@tiptap/extension-code-block-lowlight": "^3.22.1"
95
96
  },
96
97
  "peerDependenciesMeta": {
97
98
  "eslint": {
@@ -117,6 +118,9 @@
117
118
  },
118
119
  "@tiptap/suggestion": {
119
120
  "optional": true
121
+ },
122
+ "@tiptap/extension-code-block-lowlight": {
123
+ "optional": true
120
124
  }
121
125
  },
122
126
  "devDependencies": {
@@ -130,6 +134,7 @@
130
134
  "@testing-library/react": "^16.3.3",
131
135
  "@testing-library/user-event": "^14.6.7",
132
136
  "@tiptap/core": "^3.31.3",
137
+ "@tiptap/extension-code-block-lowlight": "^3.31.3",
133
138
  "@tiptap/extension-mention": "^3.31.3",
134
139
  "@tiptap/pm": "^3.31.3",
135
140
  "@tiptap/react": "^3.31.3",
@@ -162,6 +167,7 @@
162
167
  "dependencies": {
163
168
  "@base-ui/react": "^1.8.0",
164
169
  "clsx": "^2.1.1",
170
+ "lowlight": "^3.3.0",
165
171
  "tailwind-merge": "^3.5.0"
166
172
  },
167
173
  "main": "./dist/index.js",
package/registry.json CHANGED
@@ -4,9 +4,9 @@
4
4
  "kind": "package",
5
5
  "repo": "estiva-ui",
6
6
  "package": "@estiva-app/ui",
7
- "packageVersion": "0.62.1",
8
- "exports": 134,
9
- "typeExports": 141,
7
+ "packageVersion": "0.64.0",
8
+ "exports": 140,
9
+ "typeExports": 145,
10
10
  "files": null
11
11
  },
12
12
  "storybook": {
@@ -1196,6 +1196,25 @@
1196
1196
  "app": null,
1197
1197
  "looks": []
1198
1198
  },
1199
+ {
1200
+ "name": "CodeBlockColours",
1201
+ "repo": "estiva-ui",
1202
+ "kind": "component",
1203
+ "importPath": "@estiva-app/ui/editor",
1204
+ "sourceFile": "src/CodeBlockColours.ts",
1205
+ "purpose": "A code block in an editor, coloured by its language as you type.",
1206
+ "purposeFrom": "page",
1207
+ "props": [],
1208
+ "variants": [],
1209
+ "ownsBehaviours": [],
1210
+ "status": "stable",
1211
+ "migrationStage": null,
1212
+ "docsId": "primitives-codeblockcolours--docs",
1213
+ "storyId": "primitives-codeblockcolours--in-an-editor",
1214
+ "docPage": "src/CodeBlockColours.mdx",
1215
+ "app": null,
1216
+ "looks": []
1217
+ },
1199
1218
  {
1200
1219
  "name": "CollapsibleSection",
1201
1220
  "repo": "estiva-ui",
@@ -3228,6 +3247,68 @@
3228
3247
  "app": null,
3229
3248
  "looks": []
3230
3249
  },
3250
+ {
3251
+ "name": "KindIcon",
3252
+ "repo": "estiva-ui",
3253
+ "kind": "component",
3254
+ "importPath": "@estiva-app/ui",
3255
+ "sourceFile": "src/KindIcon.tsx",
3256
+ "purpose": "The icon before a thing's name that says what kind of thing it is: a topic, a project, an issue, another app's file, or a message.",
3257
+ "purposeFrom": "page",
3258
+ "props": [
3259
+ {
3260
+ "name": "kind",
3261
+ "takes": "topic | project | issue | file | message",
3262
+ "required": true,
3263
+ "note": null
3264
+ },
3265
+ {
3266
+ "name": "size",
3267
+ "takes": "14 | 16",
3268
+ "required": false,
3269
+ "note": "16 in a row or a list; 14 inside a chip."
3270
+ },
3271
+ {
3272
+ "name": "tone",
3273
+ "takes": "secondary | inherit",
3274
+ "required": false,
3275
+ "note": "`secondary`: the quieter grey a list row's icon wears."
3276
+ },
3277
+ {
3278
+ "name": "className",
3279
+ "takes": "text",
3280
+ "required": false,
3281
+ "note": "Space and position only."
3282
+ }
3283
+ ],
3284
+ "variants": [
3285
+ {
3286
+ "prop": "kind",
3287
+ "values": [
3288
+ "topic",
3289
+ "project",
3290
+ "issue",
3291
+ "file",
3292
+ "message"
3293
+ ]
3294
+ },
3295
+ {
3296
+ "prop": "tone",
3297
+ "values": [
3298
+ "secondary",
3299
+ "inherit"
3300
+ ]
3301
+ }
3302
+ ],
3303
+ "ownsBehaviours": [],
3304
+ "status": "stable",
3305
+ "migrationStage": null,
3306
+ "docsId": "primitives-kindicon--docs",
3307
+ "storyId": "primitives-kindicon--default",
3308
+ "docPage": "src/KindIcon.mdx",
3309
+ "app": null,
3310
+ "looks": []
3311
+ },
3231
3312
  {
3232
3313
  "name": "Lightbox",
3233
3314
  "repo": "estiva-ui",
@@ -3377,10 +3458,10 @@
3377
3458
  "docPage": "src/Link.mdx",
3378
3459
  "app": null,
3379
3460
  "looks": [
3380
- "text-info-default underline underline-offset-2 hover:opacity-80",
3381
- "text-info-default underline underline-offset-2 hover:opacity-80 after:rounded-md focus-visible:outline-none",
3382
- "text-info-default underline underline-offset-2 hover:opacity-80 truncate",
3383
- "text-info-default underline underline-offset-2 hover:opacity-80 truncate after:rounded-md focus-visible:outline-none",
3461
+ "text-text-interactive underline underline-offset-2 hover:opacity-80",
3462
+ "text-text-interactive underline underline-offset-2 hover:opacity-80 after:rounded-md focus-visible:outline-none",
3463
+ "text-text-interactive underline underline-offset-2 hover:opacity-80 truncate",
3464
+ "text-text-interactive underline underline-offset-2 hover:opacity-80 truncate after:rounded-md focus-visible:outline-none",
3384
3465
  "hover:underline truncate after:rounded-md focus-visible:outline-none",
3385
3466
  "underline underline-offset-2 hover:text-text-primary after:rounded-md focus-visible:outline-none",
3386
3467
  "underline underline-offset-2 hover:text-text-primary truncate",
@@ -4413,6 +4494,63 @@
4413
4494
  "app": null,
4414
4495
  "looks": []
4415
4496
  },
4497
+ {
4498
+ "name": "pastedImageName",
4499
+ "repo": "estiva-ui",
4500
+ "kind": "helper",
4501
+ "importPath": "@estiva-app/ui/editor",
4502
+ "sourceFile": "src/PastedImages.ts",
4503
+ "purpose": "`'Screenshot 2026-09-07 14-32-05.png'`: sortable, and readable on a file card.",
4504
+ "purposeFrom": "comment",
4505
+ "props": [],
4506
+ "variants": [],
4507
+ "ownsBehaviours": [],
4508
+ "status": "stable",
4509
+ "migrationStage": null,
4510
+ "docsId": "primitives-pastedimages--docs",
4511
+ "storyId": "primitives-pastedimages--in-an-editor",
4512
+ "docPage": null,
4513
+ "app": null,
4514
+ "looks": []
4515
+ },
4516
+ {
4517
+ "name": "pastedImages",
4518
+ "repo": "estiva-ui",
4519
+ "kind": "helper",
4520
+ "importPath": "@estiva-app/ui/editor",
4521
+ "sourceFile": "src/PastedImages.ts",
4522
+ "purpose": "The pictures on the clipboard, named — `[]` when there is nothing to attach.",
4523
+ "purposeFrom": "comment",
4524
+ "props": [],
4525
+ "variants": [],
4526
+ "ownsBehaviours": [],
4527
+ "status": "stable",
4528
+ "migrationStage": null,
4529
+ "docsId": "primitives-pastedimages--docs",
4530
+ "storyId": "primitives-pastedimages--in-an-editor",
4531
+ "docPage": null,
4532
+ "app": null,
4533
+ "looks": []
4534
+ },
4535
+ {
4536
+ "name": "PastedImages",
4537
+ "repo": "estiva-ui",
4538
+ "kind": "component",
4539
+ "importPath": "@estiva-app/ui/editor",
4540
+ "sourceFile": "src/PastedImages.ts",
4541
+ "purpose": "Pasting a picture into an editor attaches it.",
4542
+ "purposeFrom": "page",
4543
+ "props": [],
4544
+ "variants": [],
4545
+ "ownsBehaviours": [],
4546
+ "status": "stable",
4547
+ "migrationStage": null,
4548
+ "docsId": "primitives-pastedimages--docs",
4549
+ "storyId": "primitives-pastedimages--in-an-editor",
4550
+ "docPage": "src/PastedImages.mdx",
4551
+ "app": null,
4552
+ "looks": []
4553
+ },
4416
4554
  {
4417
4555
  "name": "peopleMenu",
4418
4556
  "repo": "estiva-ui",
@@ -5161,7 +5299,13 @@
5161
5299
  "name": "icon",
5162
5300
  "takes": "anything",
5163
5301
  "required": true,
5164
- "note": "At each row's start: a 16px icon — unless the row brings its own (`CaptionedItem.icon`)."
5302
+ "note": "At each row's start: a 16px icon — unless the row brings its own (`CaptionedItem.icon`), or `iconFor` answers."
5303
+ },
5304
+ {
5305
+ "name": "iconFor",
5306
+ "takes": "a handler",
5307
+ "required": false,
5308
+ "note": "A row's 16px icon from what it points at — its `uri` — when the list holds several kinds of thing; `undefined` from it draws `icon`."
5165
5309
  }
5166
5310
  ],
5167
5311
  "variants": [],
@@ -5269,15 +5413,15 @@
5269
5413
  "docPage": "src/RichText.mdx",
5270
5414
  "app": null,
5271
5415
  "looks": [
5272
- "[&>:is(h1,h2,h3)]:mb-1 [&>:is(h1,h2,h3)]:mt-2 [&>:is(h1,h2,h3):first-child]:mt-0 [&>:is(h1,h2,h3):last-child]:mb-0 [&>:is(h1,h2,h3)+:is(h1,h2,h3)]:mt-1 [&>:is(h1,h2)]:text-text-primary",
5273
- "[&_:is(ul,ol)]:pl-5 [&_li>p]:m-0 [&_li]:break-words [&_li]:marker:text-text-muted [&_ol]:list-decimal [&_ul]:list-disc",
5274
- "[&_blockquote>p]:m-0 [&_blockquote]:border-l-[3px] [&_blockquote]:border-border-strong [&_blockquote]:pl-2.5",
5275
- "[&_:not(pre)>code]:rounded-sm [&_:not(pre)>code]:bg-bg-code [&_:not(pre)>code]:px-1 [&_:not(pre)>code]:py-0.5 [&_code]:font-mono [&_code]:text-[0.9em] [&_code]:text-text-primary",
5276
- "[&_pre>code]:text-[1em] [&_pre]:whitespace-pre-wrap [&_pre]:break-words [&_pre]:rounded-md [&_pre]:bg-bg-code [&_pre]:p-2 [&_pre]:font-mono [&_pre]:text-[0.9em] [&_pre]:text-text-primary",
5416
+ "[&_:is(ul,ol)]:pl-5 [&_li>p]:m-0 [&_li]:break-words [&_li]:marker:text-text-secondary [&_ol]:list-decimal [&_ul]:list-disc",
5417
+ "[&_blockquote>p]:m-0 [&_blockquote]:border-l-[3px] [&_blockquote]:border-text-muted [&_blockquote]:pl-2.5",
5418
+ "[&_:not(pre)>code]:rounded-sm [&_:not(pre)>code]:bg-bg-code [&_:not(pre)>code]:px-1 [&_:not(pre)>code]:py-0.5 [&_:not(pre)>code]:text-text-code [&_code]:font-mono [&_code]:text-[0.85em] [&_code]:text-text-primary",
5419
+ "[&_pre>code]:text-[1em] [&_pre]:whitespace-pre-wrap [&_pre]:break-words [&_pre]:rounded-md [&_pre]:bg-bg-code [&_pre]:px-3 [&_pre]:py-2.5 [&_pre]:font-mono [&_pre]:text-[0.85em] [&_pre]:leading-relaxed [&_pre]:text-text-primary",
5420
+ "[&_:is(.hljs-keyword,.hljs-doctag,.hljs-type,.hljs-template-tag,.hljs-template-variable)]:text-syntax-keyword [&_.hljs-title]:text-syntax-function [&_:is(.hljs-number,.hljs-literal,.hljs-attr,.hljs-attribute,.hljs-meta,.hljs-variable,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id)]:text-syntax-constant [&_:is(.hljs-string,.hljs-regexp)]:text-syntax-string [&_:is(.hljs-built_in,.hljs-symbol)]:text-syntax-variable [&_.hljs-comment]:text-syntax-comment [&_:is(.hljs-name,.hljs-quote,.hljs-selector-tag,.hljs-selector-pseudo)]:text-syntax-tag",
5277
5421
  "[&_:is(th,td)>p]:m-0 [&_:is(th,td)]:border [&_:is(th,td)]:border-border-default [&_:is(th,td)]:px-2 [&_:is(th,td)]:py-1 [&_:is(th,td)]:text-left [&_:is(th,td)]:align-top [&_table]:w-full [&_table]:border-collapse [&_th]:bg-bg-code [&_th]:font-medium [&_th]:text-text-primary",
5278
- "[&_em]:italic [&_strong]:font-semibold [&_u]:underline [&_u]:underline-offset-2",
5279
- "text-body-2 [&>:is(h2,h3)]:text-body-2 [&>:is(h2,h3)]:font-semibold [&>h1]:text-h4",
5280
- "text-caption leading-snug [&>:is(h2,h3)]:text-caption [&>:is(h2,h3)]:font-semibold [&>h1]:text-body-2 [&>h1]:font-semibold"
5422
+ "[&_em]:italic [&_strong]:font-semibold [&_strong]:text-text-primary [&_u]:underline [&_u]:decoration-text-muted [&_u]:underline-offset-2",
5423
+ "text-body-2 leading-normal [&>:is(h2,h3)]:text-body-2 [&>:is(h2,h3)]:font-semibold [&>h1]:text-h4",
5424
+ "text-caption leading-snug [&>:is(h1,h2,h3)]:mb-0.5 [&>:is(h1,h2,h3)]:mt-1.5 [&>:is(h1,h2,h3)+:is(h1,h2,h3)]:mt-1 [&>:is(h1,h2,h3):last-child]:mb-0 [&>:is(h2,h3)]:text-caption [&>:is(h2,h3)]:font-semibold [&>h1]:text-body-2 [&>h1]:font-semibold"
5281
5425
  ]
5282
5426
  },
5283
5427
  {
@@ -6410,6 +6554,25 @@
6410
6554
  "bg-bg-field border border-border-field shadow-field hover:border-border-strong focus:border-border-focus rounded-lg text-caption text-text-primary placeholder:text-text-muted outline-none transition-colors disabled:pointer-events-none disabled:bg-bg-disabled disabled:text-text-disabled disabled:border-border-field signal:transition-shadow signal:focus:shadow-focus-ring"
6411
6555
  ]
6412
6556
  },
6557
+ {
6558
+ "name": "thingKindOf",
6559
+ "repo": "estiva-ui",
6560
+ "kind": "helper",
6561
+ "importPath": "@estiva-app/ui",
6562
+ "sourceFile": "src/KindIcon.tsx",
6563
+ "purpose": "The kind of thing behind an event kind: a message is a message, a channel or a bare file is a topic, a project and an issue are themselves, and anything else is another app's file.",
6564
+ "purposeFrom": "comment",
6565
+ "props": [],
6566
+ "variants": [],
6567
+ "ownsBehaviours": [],
6568
+ "status": "stable",
6569
+ "migrationStage": null,
6570
+ "docsId": "primitives-kindicon--docs",
6571
+ "storyId": "primitives-kindicon--default",
6572
+ "docPage": null,
6573
+ "app": null,
6574
+ "looks": []
6575
+ },
6413
6576
  {
6414
6577
  "name": "Toast",
6415
6578
  "repo": "estiva-ui",
@@ -0,0 +1,48 @@
1
+ import { Meta, Canvas } from '@storybook/addon-docs/blocks'
2
+ import * as CodeBlockColoursStories from './CodeBlockColours.stories'
3
+
4
+ <Meta of={CodeBlockColoursStories} />
5
+
6
+ # CodeBlockColours
7
+
8
+ A code block in an editor, coloured by its language as you type. The colours
9
+ are the ones RichText reads it in, so writing looks the same as reading.
10
+
11
+ <Canvas of={CodeBlockColoursStories.InAnEditor} />
12
+
13
+ ## When
14
+
15
+ - **Any editor that writes RichText**: a message box, an edit box, a
16
+ description. Use it in place of the starter kit's code block.
17
+
18
+ ## When not
19
+
20
+ - Drawing text that is already written → **RichText**, which colours its code
21
+ blocks itself.
22
+ - Code in a sentence → the code mark. It is amber and takes no other colours.
23
+
24
+ ## How
25
+
26
+ ```tsx
27
+ import StarterKit from '@tiptap/starter-kit'
28
+ import { CodeBlockColours } from '@estiva-app/ui/editor'
29
+ import { richTextClassName } from '@estiva-app/ui'
30
+
31
+ useEditor({
32
+ extensions: [StarterKit.configure({ codeBlock: false }), CodeBlockColours],
33
+ editorProps: { attributes: { class: richTextClassName() } },
34
+ })
35
+ ```
36
+
37
+ - **The editor's root must wear `richTextClassName`.** The colours live in its
38
+ class list; without it the words are named but drawn in one colour.
39
+ - The block keeps the name `codeBlock` and its `language`, so text written
40
+ before it was added reads the same.
41
+ - It colours highlight.js's 37 everyday languages. A block in any other
42
+ language, or in none, stays one colour.
43
+ - The app installs `@tiptap/extension-code-block-lowlight`, as it installs the
44
+ other Tiptap parts.
45
+
46
+ ## What it owns
47
+
48
+ Nothing. It names each word of a code block; the RichText class list colours them.
@@ -0,0 +1,37 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { EditorContent, useEditor } from '@tiptap/react'
3
+ import StarterKit from '@tiptap/starter-kit'
4
+ import { Card } from './Card'
5
+ import { CodeBlockColours } from './CodeBlockColours'
6
+ import { richTextClassName } from './RichText'
7
+
8
+ /**
9
+ * Type in the code block: each word takes its colour as you write, the same
10
+ * colours RichText reads it in. Change the theme to see each set.
11
+ */
12
+ const meta = {
13
+ title: 'Primitives/CodeBlockColours',
14
+ parameters: { layout: 'padded' },
15
+ } satisfies Meta
16
+
17
+ export default meta
18
+ type Story = StoryObj
19
+
20
+ function Box() {
21
+ const editor = useEditor({
22
+ extensions: [StarterKit.configure({ codeBlock: false }), CodeBlockColours],
23
+ content: [
24
+ '<p>A code block in a language takes its colours:</p>',
25
+ '<pre><code class="language-ts">// Count the items that are done\nexport function countDone(items: Item[]): number {\n const done = items.filter((item) =&gt; item.state === \'done\')\n return done.length + 0\n}</code></pre>',
26
+ ].join(''),
27
+ editorProps: { attributes: { class: richTextClassName('default', 'outline-none'), 'aria-label': 'Text' } },
28
+ })
29
+ return (
30
+ <Card fill="surface" className="w-full max-w-lg p-3">
31
+ <EditorContent editor={editor} />
32
+ </Card>
33
+ )
34
+ }
35
+
36
+ /** An editor with a code block in TypeScript. Type in it. */
37
+ export const InAnEditor: Story = { render: () => <Box /> }
@@ -0,0 +1,14 @@
1
+ /** The editor's code block, coloured by language like RichText's (`@estiva-app/ui/editor`). */
2
+ import { CodeBlockLowlight } from '@tiptap/extension-code-block-lowlight'
3
+ import { lowlight } from './codeColours'
4
+
5
+ /**
6
+ * A code block in the editor, with the colours RichText reads it in (Katerina,
7
+ * 9 October): the same highlight.js languages, the same classes, and the
8
+ * editor's root wears `richTextClassName`, which colours them.
9
+ *
10
+ * It replaces the starter kit's code block: `StarterKit.configure({ codeBlock: false })`,
11
+ * then add `CodeBlockColours`. The block keeps its name, `codeBlock`, and its
12
+ * `language` attribute, so nothing already written changes.
13
+ */
14
+ export const CodeBlockColours = CodeBlockLowlight.configure({ lowlight })
@@ -92,10 +92,12 @@ const urgent = editor ? urgentPubkeys(editor) : undefined
92
92
  the same query; nothing waits for it. A row's `description` is its second
93
93
  line, which the chip does not wear. A row the person has arrowed to stays
94
94
  highlighted when the list is drawn again.
95
- - **Several kinds of thing in one `[` list:** a row's `icon` is drawn in
96
- place of the list's, and the chip's `iconFor(uri)` picks the chip's from
97
- what it points at, so the chip wears what its row wore. A row with no `icon`,
98
- or a `uri` `iconFor` answers `undefined` for, keeps the one icon.
95
+ - **Several kinds of thing in one `[` list:** give `ReferenceTrigger` and
96
+ `CaptionedReference` the same `iconFor(uri)` — at 16px for the rows, 14px
97
+ for the chip — and every row and the chip it inserts wear what they point
98
+ at. **KindIcon** is the answer for Estiva's own kinds. A row's own `icon`
99
+ wins over `iconFor`; a row with neither, or a `uri` `iconFor` answers
100
+ `undefined` for, keeps the list's one icon.
99
101
 
100
102
  <Canvas of={ComposerTriggersStories.ReferencesOfSeveralKinds} />
101
103
 
@@ -145,6 +145,36 @@ describe('[', () => {
145
145
  expect(editor!.getHTML()).toContain('data-message-mention="true"')
146
146
  })
147
147
 
148
+ it('gives each row the icon `iconFor` answers for its uri; a row’s own icon wins, and no answer keeps the list’s', async () => {
149
+ const rows = [
150
+ { ...ITEMS[0], uri: 'nostr:a' },
151
+ { ...ITEMS[1], uri: 'nostr:b', icon: <i data-icon="own" /> },
152
+ { ...ITEMS[2], uri: 'nostr:c' },
153
+ ]
154
+ function Iconed() {
155
+ const made = useEditor({
156
+ extensions: [
157
+ StarterKit,
158
+ Chip,
159
+ ReferenceTrigger.configure({
160
+ items: () => rows,
161
+ nodeName: 'messageMention',
162
+ ariaLabel: 'Things',
163
+ sectionLabel: 'Things',
164
+ icon: <i data-icon="list" />,
165
+ iconFor: (uri) => (uri === 'nostr:a' ? <i data-icon="kind-a" /> : undefined),
166
+ }),
167
+ ],
168
+ })
169
+ editor = made
170
+ return <EditorContent editor={made} />
171
+ }
172
+ render(<Iconed />)
173
+ await type('[')
174
+ const icons = within(screen.getByRole('listbox')).getAllByRole('option').map((row) => row.querySelector('[data-icon]')?.getAttribute('data-icon'))
175
+ expect(icons).toEqual(['kind-a', 'own', 'list'])
176
+ })
177
+
148
178
  it('draws the app’s sections in order, skips an empty one, and redraws the open list when told', async () => {
149
179
  const listeners = new Set<() => void>()
150
180
  let late: CaptionedItem[] = []
@@ -321,8 +321,14 @@ export interface ReferenceMenuOptions {
321
321
  ariaLabel: string
322
322
  /** The section's heading. */
323
323
  sectionLabel: string
324
- /** At each row's start: a 16px icon — unless the row brings its own (`CaptionedItem.icon`). */
324
+ /** At each row's start: a 16px icon — unless the row brings its own (`CaptionedItem.icon`), or `iconFor` answers. */
325
325
  icon: ReactNode
326
+ /**
327
+ * A row's 16px icon from what it points at — its `uri` — when the list holds
328
+ * several kinds of thing; `undefined` from it draws `icon`. Give the chip the
329
+ * same answer at 14px (`CaptionedReference`'s `iconFor`), so a pick keeps it.
330
+ */
331
+ iconFor?: (uri: string) => ReactNode | undefined
326
332
  }
327
333
 
328
334
  /** A row as the list holds it: which section it is drawn under, when the list has several. */
@@ -333,7 +339,7 @@ type ListedItem = CaptionedItem & { section?: string }
333
339
  * One section headed `sectionLabel`, or — when the rows say which section they
334
340
  * are in — a heading for each, in the order the rows arrive.
335
341
  */
336
- export function referenceMenu({ ariaLabel, sectionLabel, icon }: ReferenceMenuOptions): SuggestionPopupOptions<CaptionedItem> {
342
+ export function referenceMenu({ ariaLabel, sectionLabel, icon, iconFor }: ReferenceMenuOptions): SuggestionPopupOptions<CaptionedItem> {
337
343
  return {
338
344
  ariaLabel,
339
345
  width: 'w-[658px]',
@@ -349,7 +355,7 @@ export function referenceMenu({ ariaLabel, sectionLabel, icon }: ReferenceMenuOp
349
355
  return groups
350
356
  },
351
357
  itemKey: (item) => `reference-${item.id}`,
352
- row: (item) => ({ leading: item.icon ?? icon, label: captionOf(item.label, item.snippet), description: item.description, hint: <EnterHint /> }),
358
+ row: (item) => ({ leading: item.icon ?? iconFor?.(item.uri) ?? icon, label: captionOf(item.label, item.snippet), description: item.description, hint: <EnterHint /> }),
353
359
  }
354
360
  }
355
361
 
@@ -0,0 +1,58 @@
1
+ import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
2
+ import * as KindIconStories from './KindIcon.stories'
3
+
4
+ <Meta of={KindIconStories} />
5
+
6
+ # KindIcon
7
+
8
+ The icon before a thing's name that says what kind of thing it is: a topic, a
9
+ project, an issue, another app's file, or a message. One table for every app,
10
+ so the same thing wears the same icon wherever it is named.
11
+
12
+ <Canvas of={KindIconStories.EveryKind} />
13
+
14
+ ## When
15
+
16
+ - **Before a thing's name in a list row**, at 16px: a `[` list row, a recent
17
+ file, a search result.
18
+ - **Inside a chip**, at 14px: `InlineChip`'s `icon` when a sentence names a
19
+ thing.
20
+ - **As `iconFor` of the `[` list and its chip** (`ReferenceTrigger`,
21
+ `CaptionedReference`), so every row and every chip it inserts wears its own
22
+ kind.
23
+
24
+ <Canvas of={KindIconStories.InAChip} />
25
+
26
+ ## When not
27
+
28
+ - A person → **Avatar** or **Person**. A person is a face, not a kind.
29
+ - A status (open, resolved) → **Chip**, or the app's own status mark.
30
+ - An icon for an action → **IconButton** with the icon you need.
31
+
32
+ ## How
33
+
34
+ ```tsx
35
+ import { KindIcon, thingKindOf } from '@estiva-app/ui'
36
+
37
+ <KindIcon kind="issue" /> // 16px, in a row
38
+ <KindIcon kind={thingKindOf(event.kind)} size={14} tone="inherit" /> // inside a chip
39
+ ```
40
+
41
+ - `thingKindOf(eventKind)` turns an event kind into a kind of thing. A pointer
42
+ written as `nostr:naddr…` carries its kind once decoded; a `nostr:nevent…`
43
+ is a message.
44
+ - The icon is `aria-hidden`: the name beside it says what it is.
45
+ - **Which icon is which kind is this part's.** An app never picks its own icon
46
+ for these. Its stroke is 1.5; it comes in two sizes and two tones: the
47
+ quieter grey of a list row, or `inherit`, the ink of the chip or row that
48
+ holds it.
49
+
50
+ <Canvas of={KindIconStories.InAList} />
51
+
52
+ ## What it owns
53
+
54
+ Nothing. It only draws. Clicks and keys are yours.
55
+
56
+ ## Props
57
+
58
+ <Controls of={KindIconStories.Default} />
@@ -0,0 +1,64 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { InlineChip } from './InlineChip'
3
+ import { KindIcon, type ThingKind } from './KindIcon'
4
+ import { MenuItem, MenuPanel, MenuSection } from './Menu'
5
+
6
+ const meta = {
7
+ title: 'Primitives/KindIcon',
8
+ component: KindIcon,
9
+ args: { kind: 'topic' },
10
+ argTypes: { kind: { control: 'select', options: ['topic', 'project', 'issue', 'file', 'message'] } },
11
+ } satisfies Meta<typeof KindIcon>
12
+
13
+ export default meta
14
+ type Story = StoryObj<typeof meta>
15
+
16
+ const KINDS: { kind: ThingKind; name: string }[] = [
17
+ { kind: 'topic', name: 'Topic' },
18
+ { kind: 'project', name: 'Project' },
19
+ { kind: 'issue', name: 'Issue' },
20
+ { kind: 'file', name: 'Another app’s file' },
21
+ { kind: 'message', name: 'Message' },
22
+ ]
23
+
24
+ export const Default: Story = {}
25
+
26
+ /** Every kind, at 16px, beside its name. */
27
+ export const EveryKind: Story = {
28
+ parameters: { controls: { disable: true } },
29
+ render: () => (
30
+ <div className="flex flex-col gap-2 text-body-2 text-text-primary">
31
+ {KINDS.map(({ kind, name }) => (
32
+ <div key={kind} className="flex items-center gap-2">
33
+ <KindIcon kind={kind} />
34
+ {name}
35
+ </div>
36
+ ))}
37
+ </div>
38
+ ),
39
+ }
40
+
41
+ /** At 14px inside a chip, the way a sentence names a thing. */
42
+ export const InAChip: Story = {
43
+ parameters: { controls: { disable: true } },
44
+ render: () => (
45
+ <p className="text-body-2 text-text-primary">
46
+ Tracked in <InlineChip icon={<KindIcon kind="issue" size={14} tone="inherit" />}>Ship the block model</InlineChip> and{' '}
47
+ <InlineChip icon={<KindIcon kind="topic" size={14} tone="inherit" />}>Planning</InlineChip> for now.
48
+ </p>
49
+ ),
50
+ }
51
+
52
+ /** At the start of list rows, each saying what it points at. */
53
+ export const InAList: Story = {
54
+ parameters: { controls: { disable: true } },
55
+ render: () => (
56
+ <MenuPanel className="w-72">
57
+ <MenuSection label="Files">
58
+ {KINDS.map(({ kind, name }) => (
59
+ <MenuItem key={kind} label={name} leading={<KindIcon kind={kind} />} onClick={() => {}} />
60
+ ))}
61
+ </MenuSection>
62
+ </MenuPanel>
63
+ ),
64
+ }