@estiva-app/ui 0.58.0 → 0.59.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/looks.d.ts CHANGED
@@ -33,6 +33,8 @@ export declare const FLOATING_SURFACE_CLASSES = "rounded-lg border border-border
33
33
  export declare const COUNT_TEXT_CLASSES = "font-mono text-caption tabular-nums text-text-muted";
34
34
  /** A chip's words: Chip's label and Reaction's count. */
35
35
  export declare const CHIP_TEXT_CLASSES = "text-chip signal:font-mono signal:text-small signal:font-semibold signal:tabular-nums";
36
+ /** Small secondary words that turn mono under signal: Kbd's key, CommandPalette's footer and MembersPill's count. */
37
+ export declare const MONO_CAPTION_CLASSES = "text-caption text-text-secondary signal:font-mono signal:text-small";
36
38
  /** Words typed straight into an input with no box of its own: SearchInput and ChipInput. */
37
39
  export declare const BARE_INPUT_CLASSES = "bg-transparent text-text-primary placeholder:text-text-muted outline-none";
38
40
  /** A sidebar row's shape and its words at rest, brightening under the pointer: NavItem, and SectionHeader's `row` (a folder among rows). */
@@ -1 +1 @@
1
- {"version":3,"file":"looks.d.ts","sourceRoot":"","sources":["../src/looks.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH;;GAEG;AACH,eAAO,MAAM,iBAAiB,2MAA2M,CAAA;AAEzO,wFAAwF;AACxF,eAAO,MAAM,kBAAkB,kDAAkD,CAAA;AAEjF,mEAAmE;AACnE,eAAO,MAAM,sBAAsB,iKAAiK,CAAA;AAEpM,wEAAwE;AACxE,eAAO,MAAM,wBAAwB,4DAA4D,CAAA;AAEjG,kJAAkJ;AAClJ,eAAO,MAAM,kBAAkB;;;CAGrB,CAAA;AAEV,qGAAqG;AACrG,eAAO,MAAM,mBAAmB,uJAAuJ,CAAA;AAEvL,mEAAmE;AACnE,eAAO,MAAM,wBAAwB,qEAAqE,CAAA;AAE1G,iGAAiG;AACjG,eAAO,MAAM,kBAAkB,wDAAwD,CAAA;AAEvF,yDAAyD;AACzD,eAAO,MAAM,iBAAiB,0FAA0F,CAAA;AAExH,4FAA4F;AAC5F,eAAO,MAAM,kBAAkB,8EAA8E,CAAA;AAE7G,4IAA4I;AAE5I,eAAO,MAAM,mBAAmB,iCAAiC,CAAA;AACjE,eAAO,MAAM,wBAAwB,gDAAgD,CAAA;AAErF,2FAA2F;AAC3F,eAAO,MAAM,qBAAqB,kEAAkE,CAAA;AAEpG,2IAA2I;AAC3I,eAAO,MAAM,oBAAoB,gGAAgG,CAAA;AAEjI;;;;;GAKG;AACH,eAAO,MAAM,yBAAyB,kLAAkL,CAAA"}
1
+ {"version":3,"file":"looks.d.ts","sourceRoot":"","sources":["../src/looks.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH;;GAEG;AACH,eAAO,MAAM,iBAAiB,2MAA2M,CAAA;AAEzO,wFAAwF;AACxF,eAAO,MAAM,kBAAkB,kDAAkD,CAAA;AAEjF,mEAAmE;AACnE,eAAO,MAAM,sBAAsB,iKAAiK,CAAA;AAEpM,wEAAwE;AACxE,eAAO,MAAM,wBAAwB,4DAA4D,CAAA;AAEjG,kJAAkJ;AAClJ,eAAO,MAAM,kBAAkB;;;CAGrB,CAAA;AAEV,qGAAqG;AACrG,eAAO,MAAM,mBAAmB,uJAAuJ,CAAA;AAEvL,mEAAmE;AACnE,eAAO,MAAM,wBAAwB,qEAAqE,CAAA;AAE1G,iGAAiG;AACjG,eAAO,MAAM,kBAAkB,wDAAwD,CAAA;AAEvF,yDAAyD;AACzD,eAAO,MAAM,iBAAiB,0FAA0F,CAAA;AAExH,qHAAqH;AACrH,eAAO,MAAM,oBAAoB,wEAAwE,CAAA;AAEzG,4FAA4F;AAC5F,eAAO,MAAM,kBAAkB,8EAA8E,CAAA;AAE7G,4IAA4I;AAE5I,eAAO,MAAM,mBAAmB,iCAAiC,CAAA;AACjE,eAAO,MAAM,wBAAwB,gDAAgD,CAAA;AAErF,2FAA2F;AAC3F,eAAO,MAAM,qBAAqB,kEAAkE,CAAA;AAEpG,2IAA2I;AAC3I,eAAO,MAAM,oBAAoB,gGAAgG,CAAA;AAEjI;;;;;GAKG;AACH,eAAO,MAAM,yBAAyB,kLAAkL,CAAA"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@estiva-app/ui",
3
- "version": "0.58.0",
3
+ "version": "0.59.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",
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.58.0",
8
- "exports": 131,
9
- "typeExports": 135,
7
+ "packageVersion": "0.59.0",
8
+ "exports": 133,
9
+ "typeExports": 140,
10
10
  "files": null
11
11
  },
12
12
  "storybook": {
@@ -3152,10 +3152,9 @@
3152
3152
  "docPage": "src/Kbd.mdx",
3153
3153
  "app": null,
3154
3154
  "looks": [
3155
- "whitespace-nowrap rounded-sm border border-border-strong bg-bg-inset font-sans text-caption text-text-secondary signal:border-b-2 signal:bg-bg-wash signal:font-mono signal:text-small ship:border-b-2 ship:bg-bg-wash ship:font-mono ship:text-small leaf:font-mono leaf:text-small",
3156
- "whitespace-nowrap rounded-sm border border-border-strong bg-bg-inset font-sans text-caption text-text-secondary signal:border-b-2 signal:bg-bg-wash signal:font-mono signal:text-small ship:border-b-2 ship:bg-bg-wash ship:font-mono ship:text-small leaf:font-mono leaf:text-small border-border-tooltip-key bg-bg-tooltip-key text-text-tooltip-key signal:bg-bg-tooltip-key ship:bg-bg-tooltip-key",
3157
- "whitespace-nowrap rounded-sm border border-border-strong bg-bg-inset font-sans text-caption text-text-secondary",
3158
- "signal:border-b-2 signal:bg-bg-wash signal:font-mono signal:text-small",
3155
+ "whitespace-nowrap rounded-sm border border-border-strong bg-bg-inset font-sans text-caption text-text-secondary signal:font-mono signal:text-small signal:border-b-2 signal:bg-bg-wash ship:border-b-2 ship:bg-bg-wash ship:font-mono ship:text-small leaf:font-mono leaf:text-small",
3156
+ "whitespace-nowrap rounded-sm border border-border-strong bg-bg-inset font-sans text-caption text-text-secondary signal:font-mono signal:text-small signal:border-b-2 signal:bg-bg-wash ship:border-b-2 ship:bg-bg-wash ship:font-mono ship:text-small leaf:font-mono leaf:text-small border-border-tooltip-key bg-bg-tooltip-key text-text-tooltip-key signal:bg-bg-tooltip-key ship:bg-bg-tooltip-key",
3157
+ "whitespace-nowrap rounded-sm border border-border-strong bg-bg-inset font-sans",
3159
3158
  "ship:border-b-2 ship:bg-bg-wash ship:font-mono ship:text-small",
3160
3159
  "border-border-tooltip-key bg-bg-tooltip-key text-text-tooltip-key signal:bg-bg-tooltip-key ship:bg-bg-tooltip-key"
3161
3160
  ]
@@ -3406,6 +3405,149 @@
3406
3405
  "app": null,
3407
3406
  "looks": []
3408
3407
  },
3408
+ {
3409
+ "name": "MembersDialog",
3410
+ "repo": "estiva-ui",
3411
+ "kind": "component",
3412
+ "importPath": "@estiva-app/ui",
3413
+ "sourceFile": "src/MembersDialog.tsx",
3414
+ "purpose": "Who is in this, with joining, leaving and adding people — the list a MembersPill opens.",
3415
+ "purposeFrom": "page",
3416
+ "props": [
3417
+ {
3418
+ "name": "members",
3419
+ "takes": "MembersDialogMember[]",
3420
+ "required": true,
3421
+ "note": "The roster, in the order to draw it. The viewer's own row is the caller's to name (\"You\")."
3422
+ },
3423
+ {
3424
+ "name": "count",
3425
+ "takes": "number",
3426
+ "required": false,
3427
+ "note": "The number in the title's chip. Defaults to how many rows there are."
3428
+ },
3429
+ {
3430
+ "name": "self",
3431
+ "takes": "a handler",
3432
+ "required": false,
3433
+ "note": "The viewer's own row on top: \"Join\" when they are not in, \"Leave\" when they are."
3434
+ },
3435
+ {
3436
+ "name": "candidates",
3437
+ "takes": "MembersDialogCandidate[]",
3438
+ "required": false,
3439
+ "note": "Who \"Add members\" may offer. Members are left out by id."
3440
+ },
3441
+ {
3442
+ "name": "onAdd",
3443
+ "takes": "a handler",
3444
+ "required": false,
3445
+ "note": "Adds the people chosen. With it, the \"Add members\" row shows and opens the add layer."
3446
+ },
3447
+ {
3448
+ "name": "note",
3449
+ "takes": "anything",
3450
+ "required": false,
3451
+ "note": "One line under the roster — what membership does and does not cover."
3452
+ },
3453
+ {
3454
+ "name": "initialView",
3455
+ "takes": "list | add",
3456
+ "required": false,
3457
+ "note": "`add` opens straight on the add layer."
3458
+ },
3459
+ {
3460
+ "name": "onClose",
3461
+ "takes": "a handler",
3462
+ "required": true,
3463
+ "note": null
3464
+ }
3465
+ ],
3466
+ "variants": [
3467
+ {
3468
+ "prop": "initialView",
3469
+ "values": [
3470
+ "list",
3471
+ "add"
3472
+ ]
3473
+ }
3474
+ ],
3475
+ "ownsBehaviours": [
3476
+ {
3477
+ "id": "portal",
3478
+ "behaviour": "Floats on top of the page"
3479
+ },
3480
+ {
3481
+ "id": "press-outside",
3482
+ "behaviour": "Closes on a press outside"
3483
+ },
3484
+ {
3485
+ "id": "page-keys",
3486
+ "behaviour": "Takes its keys by itself"
3487
+ },
3488
+ {
3489
+ "id": "focus",
3490
+ "behaviour": "Holds focus inside while open, and gives it back"
3491
+ },
3492
+ {
3493
+ "id": "scroll-lock",
3494
+ "behaviour": "Stops the page behind it scrolling"
3495
+ }
3496
+ ],
3497
+ "status": "stable",
3498
+ "migrationStage": null,
3499
+ "docsId": "primitives-membersdialog--docs",
3500
+ "storyId": "primitives-membersdialog--member",
3501
+ "docPage": "src/MembersDialog.mdx",
3502
+ "app": null,
3503
+ "looks": []
3504
+ },
3505
+ {
3506
+ "name": "MembersPill",
3507
+ "repo": "estiva-ui",
3508
+ "kind": "component",
3509
+ "importPath": "@estiva-app/ui",
3510
+ "sourceFile": "src/MembersPill.tsx",
3511
+ "purpose": "Who is in this: up to three faces and the total, in a small outlined pill that opens the full list.",
3512
+ "purposeFrom": "page",
3513
+ "props": [
3514
+ {
3515
+ "name": "members",
3516
+ "takes": "AvatarGroupMember[]",
3517
+ "required": true,
3518
+ "note": "Who is in it, in order. Up to three faces are drawn."
3519
+ },
3520
+ {
3521
+ "name": "count",
3522
+ "takes": "number",
3523
+ "required": false,
3524
+ "note": "The number beside the faces. Defaults to how many members there are."
3525
+ },
3526
+ {
3527
+ "name": "onClick",
3528
+ "takes": "a handler",
3529
+ "required": false,
3530
+ "note": "Opens the list — usually a `MembersDialog`."
3531
+ },
3532
+ {
3533
+ "name": "className",
3534
+ "takes": "text",
3535
+ "required": false,
3536
+ "note": null
3537
+ }
3538
+ ],
3539
+ "variants": [],
3540
+ "ownsBehaviours": [],
3541
+ "status": "stable",
3542
+ "migrationStage": null,
3543
+ "docsId": "primitives-memberspill--docs",
3544
+ "storyId": "primitives-memberspill--two-members",
3545
+ "docPage": "src/MembersPill.mdx",
3546
+ "app": null,
3547
+ "looks": [
3548
+ "text-caption text-text-secondary signal:font-mono signal:text-small signal:tabular-nums"
3549
+ ]
3550
+ },
3409
3551
  {
3410
3552
  "name": "Menu",
3411
3553
  "repo": "estiva-ui",
@@ -4969,7 +5111,7 @@
4969
5111
  "name": "icon",
4970
5112
  "takes": "anything",
4971
5113
  "required": true,
4972
- "note": "At each row's start: a 16px icon."
5114
+ "note": "At each row's start: a 16px icon — unless the row brings its own (`CaptionedItem.icon`)."
4973
5115
  }
4974
5116
  ],
4975
5117
  "variants": [],
@@ -15,6 +15,7 @@ import { Autocomplete } from '@base-ui/react/autocomplete'
15
15
  import type { BaseUIEvent } from '@base-ui/react/types'
16
16
  import { IconLoader2, IconSearch } from '@tabler/icons-react'
17
17
  import { cn } from './cn'
18
+ import { MONO_CAPTION_CLASSES } from './looks'
18
19
  import { Button } from './Button'
19
20
  import { Card } from './Card'
20
21
  import { InputChip } from './ChipInput'
@@ -176,7 +177,7 @@ type Key = [key: string, word: string]
176
177
  function Footer({ keys }: { keys: Key[] }) {
177
178
  const { where } = usePalette('The footer')
178
179
  return (
179
- <div className="flex h-9 shrink-0 items-center gap-4 border-t border-border-subtle px-5 text-caption text-text-secondary signal:font-mono signal:text-small signal:text-text-muted leaf:font-mono leaf:text-small">
180
+ <div className={cn('flex h-9 shrink-0 items-center gap-4 border-t border-border-subtle px-5', MONO_CAPTION_CLASSES, 'signal:text-text-muted leaf:font-mono leaf:text-small')}>
180
181
  <span className="min-w-0 flex-1 truncate">{where}</span>
181
182
  {keys.map(([key, word]) => (
182
183
  <span key={key} className="flex shrink-0 items-center gap-1.5">
@@ -92,6 +92,13 @@ 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.
99
+
100
+ <Canvas of={ComposerTriggersStories.ReferencesOfSeveralKinds} />
101
+
95
102
  - **An app's own node name:** `CaptionedReference.extend({ name: 'yourName' })`
96
103
  and `ReferenceTrigger.configure({ nodeName: 'yourName' })`. The chip's HTML
97
104
  attribute follows the name (`data-your-name`).
@@ -1,5 +1,5 @@
1
1
  import type { Meta, StoryObj } from '@storybook/react-vite'
2
- import { IconSquareRounded } from '@tabler/icons-react'
2
+ import { IconCircle, IconSquareRounded, IconTriangle } from '@tabler/icons-react'
3
3
  import { EditorContent, useEditor } from '@tiptap/react'
4
4
  import StarterKit from '@tiptap/starter-kit'
5
5
  import { richTextClassName } from './RichText'
@@ -59,6 +59,35 @@ const SECTIONS = (query: string): ReferenceSection[] => [
59
59
  { label: 'Group two', items: matching(DESCRIBED, query) },
60
60
  ]
61
61
 
62
+ /* Three kinds of thing in one list, each row with its own icon, and the chip it inserts wearing the same. */
63
+ const KIND_ICONS = { circle: IconCircle, triangle: IconTriangle, square: IconSquareRounded } as const
64
+ type Kind = keyof typeof KIND_ICONS
65
+ const kindOf = (uri: string | null): Kind | undefined => (Object.keys(KIND_ICONS) as Kind[]).find((kind) => uri?.includes(kind))
66
+ const KINDS = (query: string): ReferenceSection[] => [
67
+ {
68
+ label: 'Items',
69
+ items: matching(
70
+ (['circle', 'triangle', 'square'] as const).map((kind, i) => {
71
+ const Icon = KIND_ICONS[kind]
72
+ return {
73
+ id: `kind-${kind}`,
74
+ label: `Item ${['one', 'two', 'three'][i]}`,
75
+ snippet: '',
76
+ uri: `nostr:${kind}`,
77
+ icon: <Icon size={16} stroke={1.5} className="text-text-secondary" />,
78
+ }
79
+ }),
80
+ query,
81
+ ),
82
+ },
83
+ ]
84
+ const kindIcon = (uri: string | null) => {
85
+ const kind = kindOf(uri)
86
+ if (!kind) return undefined
87
+ const Icon = KIND_ICONS[kind]
88
+ return <Icon size={14} stroke={1.5} />
89
+ }
90
+
62
91
  const INSERT: SlashSection = {
63
92
  label: 'Insert',
64
93
  commands: [
@@ -67,13 +96,13 @@ const INSERT: SlashSection = {
67
96
  ],
68
97
  }
69
98
 
70
- function Composer({ code, sections }: { code: 'inline' | 'block'; sections?: typeof SECTIONS }) {
99
+ function Composer({ code, sections, kinds = false }: { code: 'inline' | 'block'; sections?: typeof SECTIONS; kinds?: boolean }) {
71
100
  const editor = useEditor({
72
101
  extensions: [
73
102
  StarterKit,
74
103
  PersonMention.configure({ people: () => PEOPLE }),
75
104
  UrgentPersonMention.configure({ people: () => PEOPLE }),
76
- CaptionedReference.configure({ icon: <IconSquareRounded size={14} stroke={1.5} /> }),
105
+ CaptionedReference.configure({ icon: <IconSquareRounded size={14} stroke={1.5} />, iconFor: kinds ? kindIcon : undefined }),
77
106
  ReferenceTrigger.configure({
78
107
  items: () => ITEMS,
79
108
  sections,
@@ -83,7 +112,9 @@ function Composer({ code, sections }: { code: 'inline' | 'block'; sections?: typ
83
112
  }),
84
113
  SlashCommands.configure({ sections: [formatSection(code), INSERT] }),
85
114
  ],
86
- content: '<p>Type @, !@, [ or / after a space: </p>',
115
+ content: kinds
116
+ ? '<p>Picked from [: <span data-captioned-reference="true" data-id="kind-circle" data-label="Item one" data-snippet="" data-uri="nostr:circle"></span> and <span data-captioned-reference="true" data-id="kind-triangle" data-label="Item two" data-snippet="" data-uri="nostr:triangle"></span>. Type [ for more: </p>'
117
+ : '<p>Type @, !@, [ or / after a space: </p>',
87
118
  editorProps: { attributes: { class: richTextClassName('default', 'min-h-[80px] w-[480px] rounded-md border border-border-default p-3 outline-none') } },
88
119
  })
89
120
  return <EditorContent editor={editor} />
@@ -97,3 +128,11 @@ export const CodeAsABlock: Story = { render: () => <Composer code="block" /> }
97
128
 
98
129
  /** `[` with the app's own groups, each headed, a row with a second line. */
99
130
  export const ReferencesInSections: Story = { render: () => <Composer code="inline" sections={SECTIONS} /> }
131
+
132
+ /**
133
+ * `[` offering several kinds of thing: each row brings its own icon
134
+ * (`CaptionedItem.icon`), and the chip it inserts wears the same one, from its
135
+ * `uri` (`iconFor`). A row with no icon, or a chip `iconFor` cannot name, keeps
136
+ * the list's and the chip's one icon.
137
+ */
138
+ export const ReferencesOfSeveralKinds: Story = { render: () => <Composer code="inline" sections={KINDS} kinds /> }
@@ -206,6 +206,39 @@ describe('[', () => {
206
206
  expect(listeners.size).toBe(0)
207
207
  })
208
208
 
209
+ it('draws a row’s own icon in place of the list’s, and the chip its `iconFor` answer (7 October)', async () => {
210
+ const own = [
211
+ { id: 'a'.repeat(64), label: 'Plan', snippet: '', uri: 'nostr:naddr1plan', icon: <svg data-icon="row-own" /> },
212
+ { id: 'b'.repeat(64), label: 'Note', snippet: '', uri: 'nostr:naddr1note' },
213
+ ]
214
+ function Kinds() {
215
+ const made = useEditor({
216
+ extensions: [
217
+ StarterKit,
218
+ CaptionedReference.extend({ name: 'messageMention' }).configure({
219
+ icon: <svg data-icon="chip-default" />,
220
+ iconFor: (uri) => (uri === 'nostr:naddr1plan' ? <svg data-icon="chip-own" /> : undefined),
221
+ }),
222
+ ReferenceTrigger.configure({ items: () => own, nodeName: 'messageMention', ariaLabel: 'Things', sectionLabel: 'Things', icon: <svg data-icon="list" /> }),
223
+ ],
224
+ })
225
+ editor = made
226
+ return <EditorContent editor={made} />
227
+ }
228
+ render(<Kinds />)
229
+ await type('[')
230
+ const rows = within(screen.getByRole('listbox')).getAllByRole('option')
231
+ expect(rows[0].querySelector('[data-icon="row-own"]')).not.toBeNull()
232
+ expect(rows[1].querySelector('[data-icon="list"]')).not.toBeNull()
233
+ await press('Enter')
234
+ await type('[')
235
+ await press('ArrowDown')
236
+ await press('Enter')
237
+ const chips = document.querySelectorAll('[data-node-view-wrapper]')
238
+ expect(chips[0].querySelector('[data-icon="chip-own"]')).not.toBeNull()
239
+ expect(chips[1].querySelector('[data-icon="chip-default"]')).not.toBeNull()
240
+ })
241
+
209
242
  it('matches who said it, or the text it searches', () => {
210
243
  expect(filterReferences(ITEMS, 'bea').map((item) => item.id)).toEqual([ITEMS[7].id])
211
244
  expect(filterReferences(ITEMS, '**line** 3').map((item) => item.id)).toEqual([ITEMS[3].id])
@@ -229,6 +229,12 @@ export interface CaptionedItem {
229
229
  search?: string
230
230
  /** The row's second line, e.g. where the thing sits. The chip does not wear it. */
231
231
  description?: string
232
+ /**
233
+ * This row's own 16px icon, in place of the list's `icon`: what the thing is,
234
+ * when the list holds several kinds — a topic, a project, a message
235
+ * (Katerina, 7 October). Absent, the list's icon.
236
+ */
237
+ icon?: ReactNode
232
238
  }
233
239
 
234
240
  /** One labelled group of `[`'s rows, in the order the list draws them. */
@@ -256,6 +262,12 @@ const captionOf = (label: string, snippet: string) => (snippet ? `${label}: ${sn
256
262
  export interface CaptionedReferenceOptions {
257
263
  /** Drawn before the caption: a 14px icon. */
258
264
  icon: ReactNode
265
+ /**
266
+ * The chip's 14px icon from what it points at — its `uri` — when chips name
267
+ * several kinds of thing; `undefined` from it, or no `iconFor`, draws `icon`.
268
+ * The same answer the list's row gave (Katerina, 7 October).
269
+ */
270
+ iconFor?: (uri: string | null) => ReactNode | undefined
259
271
  }
260
272
 
261
273
  /**
@@ -270,7 +282,7 @@ export const CaptionedReference = Mention.extend<MentionOptions & CaptionedRefer
270
282
  inline: true,
271
283
  atom: true,
272
284
  addOptions() {
273
- return { ...this.parent!(), HTMLAttributes: {}, icon: null, suggestion: { char: '\0', items: () => [] } }
285
+ return { ...this.parent!(), HTMLAttributes: {}, icon: null, iconFor: undefined, suggestion: { char: '\0', items: () => [] } }
274
286
  },
275
287
  addAttributes() {
276
288
  return {
@@ -291,11 +303,11 @@ export const CaptionedReference = Mention.extend<MentionOptions & CaptionedRefer
291
303
  return [{ tag: `span[data-${this.name.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)}]` }]
292
304
  },
293
305
  addNodeView() {
294
- const icon = this.options.icon
306
+ const { icon, iconFor } = this.options
295
307
  return ReactNodeViewRenderer(
296
308
  ({ node }: ReactNodeViewProps) => (
297
309
  <NodeViewWrapper as="span" className={inlineChipClassName('neutral', 'max-w-[24ch] cursor-default')}>
298
- <span className="flex items-center justify-center w-4 h-4 shrink-0 text-text-secondary">{icon}</span>
310
+ <span className="flex items-center justify-center w-4 h-4 shrink-0 text-text-secondary">{iconFor?.(node.attrs.uri ?? null) ?? icon}</span>
299
311
  <span className="truncate">{captionOf(node.attrs.label, node.attrs.snippet)}</span>
300
312
  </NodeViewWrapper>
301
313
  ),
@@ -309,7 +321,7 @@ export interface ReferenceMenuOptions {
309
321
  ariaLabel: string
310
322
  /** The section's heading. */
311
323
  sectionLabel: string
312
- /** At each row's start: a 16px icon. */
324
+ /** At each row's start: a 16px icon — unless the row brings its own (`CaptionedItem.icon`). */
313
325
  icon: ReactNode
314
326
  }
315
327
 
@@ -337,7 +349,7 @@ export function referenceMenu({ ariaLabel, sectionLabel, icon }: ReferenceMenuOp
337
349
  return groups
338
350
  },
339
351
  itemKey: (item) => `reference-${item.id}`,
340
- row: (item) => ({ leading: icon, label: captionOf(item.label, item.snippet), description: item.description, hint: <EnterHint /> }),
352
+ row: (item) => ({ leading: item.icon ?? icon, label: captionOf(item.label, item.snippet), description: item.description, hint: <EnterHint /> }),
341
353
  }
342
354
  }
343
355
 
package/src/Kbd.tsx CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { ReactNode } from 'react'
2
2
  import { cn } from './cn'
3
+ import { MONO_CAPTION_CLASSES } from './looks'
3
4
 
4
5
  /**
5
6
  * A keyboard hint — the small chip that names the key which does the same thing.
@@ -36,8 +37,9 @@ export function Kbd({ children, className, tone = 'default' }: KbdProps) {
36
37
  return (
37
38
  <kbd
38
39
  className={cn(
39
- 'inline-flex shrink-0 items-center justify-center whitespace-nowrap rounded-sm border border-border-strong bg-bg-inset px-1 py-px font-sans text-caption text-text-secondary',
40
- 'signal:border-b-2 signal:pt-[2px] signal:pb-px signal:bg-bg-wash signal:font-mono signal:text-small',
40
+ 'inline-flex shrink-0 items-center justify-center whitespace-nowrap rounded-sm border border-border-strong bg-bg-inset px-1 py-px font-sans',
41
+ MONO_CAPTION_CLASSES,
42
+ 'signal:border-b-2 signal:pt-[2px] signal:pb-px signal:bg-bg-wash',
41
43
  'ship:border-b-2 ship:pt-[2px] ship:pb-px ship:bg-bg-wash ship:font-mono ship:text-small',
42
44
  // Leaf sets its keys in mono, as Signal does (Katerina, 28 September): the type, not the key's shape.
43
45
  'leaf:font-mono leaf:text-small',
@@ -0,0 +1,69 @@
1
+ import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
2
+ import * as MembersDialogStories from './MembersDialog.stories'
3
+
4
+ <Meta of={MembersDialogStories} />
5
+
6
+ # MembersDialog
7
+
8
+ Who is in this, with joining, leaving and adding people — the list a
9
+ **MembersPill** opens.
10
+
11
+ <Canvas of={MembersDialogStories.Member} />
12
+
13
+ ## When
14
+
15
+ - The full roster behind a **MembersPill**: everyone in it, the viewer's own
16
+ Join or Leave, and "Add members" to bring somebody in.
17
+
18
+ ## When not
19
+
20
+ - A glance at who is in it → **MembersPill** or **AvatarGroup**.
21
+ - Picking people for something that is not membership → **ChipInput** in your own **DialogShell**.
22
+
23
+ ## How
24
+
25
+ ```tsx
26
+ import { MembersDialog } from '@estiva-app/ui'
27
+
28
+ const roster: { id: string; name: string; picture?: string; caption?: string }[] = []
29
+ const directory: { id: string; label: string; picture?: string }[] = []
30
+ const [open, setOpen] = useState(false)
31
+
32
+ {open && (
33
+ <MembersDialog
34
+ members={roster}
35
+ self={{ action: 'join', onToggle: () => {} }}
36
+ candidates={directory}
37
+ onAdd={(chosen) => {}}
38
+ onClose={() => setOpen(false)}
39
+ />
40
+ )}
41
+ ```
42
+
43
+ - A row is `{ id, name, picture?, caption? }`. The caption is the second
44
+ line: a role, a relation such as "Assignee", or a state. Name the viewer's
45
+ own row "You".
46
+ - `self` puts **Join** or **Leave** on top. Leave it out while membership is
47
+ still being read: no row is better than a wrong one. The row waits while
48
+ `onToggle`'s promise runs.
49
+ - `onAdd` shows **Add members**, which opens the add layer: an "Invite people"
50
+ **ChipInput** over `candidates`, leaving out anyone already a member by id.
51
+ Return `false` to stay on the layer; otherwise it returns to the roster.
52
+ - `count` is the number in the title when it differs from the rows — a row
53
+ for somebody who left but still holds a role, say.
54
+ - `note` is one line under the roster, for what membership does not cover.
55
+
56
+ ## What it owns
57
+
58
+ It does these by itself, on top of what **DialogShell** and **ChipInput** own.
59
+ What a join, a leave or an add does is yours.
60
+
61
+ | It does | So you never write | Refused when written by hand |
62
+ |---|---|---|
63
+ | Goes between the roster and the add layer, and back after an add | a view state, or a second dialog | |
64
+ | Waits on the Join or Leave row while `onToggle` runs, so one press is one change | a busy flag around it | |
65
+ | Leaves current members out of the picker | a filter over your directory | |
66
+
67
+ ## Props
68
+
69
+ <Controls of={MembersDialogStories.Member} />
@@ -0,0 +1,68 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { MembersDialog } from './MembersDialog'
3
+
4
+ const people = [
5
+ { id: 'ana', label: 'Ana Duarte' },
6
+ { id: 'ravi', label: 'Ravi Mehta' },
7
+ { id: 'marta', label: 'Marta Silva' },
8
+ { id: 'jonas', label: 'Jonas Weber' },
9
+ ]
10
+
11
+ const meta = {
12
+ title: 'Primitives/MembersDialog',
13
+ component: MembersDialog,
14
+ args: {
15
+ members: [
16
+ { id: 'me', name: 'You', caption: 'Product designer' },
17
+ { id: 'ana', name: 'Ana Duarte', caption: 'Assignee' },
18
+ ],
19
+ self: { action: 'leave', onToggle: () => {} },
20
+ candidates: people,
21
+ onAdd: () => {},
22
+ onClose: () => {},
23
+ },
24
+ } satisfies Meta<typeof MembersDialog>
25
+
26
+ export default meta
27
+ type Story = StoryObj<typeof meta>
28
+
29
+ /** A member: Leave and "Add members" on top. */
30
+ export const Member: Story = {}
31
+
32
+ /** Not a member yet: Join on top, and nothing to add until you are in. */
33
+ export const NotAMember: Story = {
34
+ args: {
35
+ members: [{ id: 'ana', name: 'Ana Duarte', caption: 'Assignee' }],
36
+ self: { action: 'join', onToggle: () => {} },
37
+ onAdd: undefined,
38
+ },
39
+ }
40
+
41
+ /** Membership still being read: no row on top rather than a wrong one. */
42
+ export const Unknown: Story = {
43
+ args: { self: undefined, onAdd: undefined },
44
+ }
45
+
46
+ /** Someone who left but still holds a role, and a line about what membership covers. */
47
+ export const WithNote: Story = {
48
+ args: {
49
+ members: [
50
+ { id: 'me', name: 'You' },
51
+ { id: 'ravi', name: 'Ravi Mehta', caption: 'Assignee · not following' },
52
+ ],
53
+ count: 1,
54
+ note: 'Leaving covers this page only; the items inside it still tell you about their comments.',
55
+ },
56
+ }
57
+
58
+ /** More rows than fit: the roster scrolls inside the dialog. */
59
+ export const Long: Story = {
60
+ args: {
61
+ members: Array.from({ length: 14 }, (_, i) => ({ id: String(i), name: `Person ${i + 1}`, caption: i % 3 === 0 ? 'Owner' : undefined })),
62
+ },
63
+ }
64
+
65
+ /** Opened straight on the add layer. */
66
+ export const AddLayer: Story = {
67
+ args: { initialView: 'add' },
68
+ }
@@ -0,0 +1,69 @@
1
+ // @vitest-environment jsdom
2
+ /** What the MembersDialog page claims, pinned. */
3
+ import { afterEach, describe, expect, it, vi } from 'vitest'
4
+ import { cleanup, render, screen } from '@testing-library/react'
5
+ import userEvent from '@testing-library/user-event'
6
+ import { MembersDialog } from './MembersDialog'
7
+ import { MembersPill } from './MembersPill'
8
+
9
+ afterEach(cleanup)
10
+
11
+ const members = [
12
+ { id: 'me', name: 'You' },
13
+ { id: 'ana', name: 'Ana Duarte', caption: 'Assignee' },
14
+ ]
15
+
16
+ describe('MembersDialog', () => {
17
+ it('lists every member with their caption, and counts them in the title', async () => {
18
+ render(<MembersDialog members={members} onClose={() => {}} />)
19
+ const dialog = await screen.findByRole('dialog', { name: /Members/ })
20
+ expect(dialog.textContent).toContain('Ana Duarte')
21
+ expect(dialog.textContent).toContain('Assignee')
22
+ expect(dialog.textContent).toContain('2')
23
+ })
24
+
25
+ it('offers no Join, Leave or Add when none is given', async () => {
26
+ render(<MembersDialog members={members} onClose={() => {}} />)
27
+ await screen.findByRole('dialog')
28
+ expect(screen.queryByRole('button', { name: /^(Join|Leave|Add members)$/ })).toBeNull()
29
+ })
30
+
31
+ it('says Join to a non-member and Leave to a member, and calls onToggle once', async () => {
32
+ const user = userEvent.setup()
33
+ const onToggle = vi.fn()
34
+ const { rerender } = render(<MembersDialog members={members} self={{ action: 'join', onToggle }} onClose={() => {}} />)
35
+ await user.click(await screen.findByRole('button', { name: 'Join' }))
36
+ expect(onToggle).toHaveBeenCalledTimes(1)
37
+ rerender(<MembersDialog members={members} self={{ action: 'leave', onToggle }} onClose={() => {}} />)
38
+ expect(await screen.findByRole('button', { name: 'Leave' })).toBeTruthy()
39
+ })
40
+
41
+ it('goes to the add layer and back, and Invite waits for a choice', async () => {
42
+ const user = userEvent.setup()
43
+ render(<MembersDialog members={members} candidates={[{ id: 'ravi', label: 'Ravi Mehta' }]} onAdd={() => {}} onClose={() => {}} />)
44
+ await user.click(await screen.findByRole('button', { name: 'Add members' }))
45
+ expect((await screen.findByRole('button', { name: 'Invite' })).hasAttribute('disabled')).toBe(true)
46
+ await user.click(screen.getByRole('button', { name: 'Back to members' }))
47
+ expect(await screen.findByRole('button', { name: 'Add members' })).toBeTruthy()
48
+ })
49
+
50
+ it('stays on the add layer when onAdd returns false', async () => {
51
+ const user = userEvent.setup()
52
+ const onAdd = vi.fn(() => false)
53
+ render(<MembersDialog members={members} candidates={[{ id: 'ravi', label: 'Ravi Mehta' }]} onAdd={onAdd} initialView="add" onClose={() => {}} />)
54
+ await user.type(await screen.findByRole('combobox'), 'Ravi')
55
+ await user.click(await screen.findByRole('option', { name: /Ravi Mehta/ }))
56
+ await user.click(screen.getByRole('button', { name: 'Invite' }))
57
+ expect(onAdd).toHaveBeenCalledWith([{ id: 'ravi', label: 'Ravi Mehta' }])
58
+ expect(screen.getByRole('button', { name: 'Invite' })).toBeTruthy()
59
+ })
60
+ })
61
+
62
+ describe('MembersPill', () => {
63
+ it('names the total, and draws nothing for none', () => {
64
+ const { container, rerender } = render(<MembersPill members={[{ name: 'Ana Duarte' }]} count={4} onClick={() => {}} />)
65
+ expect(screen.getByRole('button', { name: '4 members' })).toBeTruthy()
66
+ rerender(<MembersPill members={[]} />)
67
+ expect(container.textContent).toBe('')
68
+ })
69
+ })