@estiva-app/ui 0.58.0 → 0.60.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/AppProvider.d.ts +47 -0
- package/dist/AppProvider.d.ts.map +1 -0
- package/dist/AppShell.d.ts +6 -2
- package/dist/AppShell.d.ts.map +1 -1
- package/dist/Avatar.d.ts +1 -1
- package/dist/Avatar.d.ts.map +1 -1
- package/dist/Chip.d.ts.map +1 -1
- package/dist/CommandPalette.d.ts.map +1 -1
- package/dist/ComposerTriggers.d.ts +13 -1
- package/dist/ComposerTriggers.d.ts.map +1 -1
- package/dist/IconButton.d.ts.map +1 -1
- package/dist/InlineChip.d.ts.map +1 -1
- package/dist/Kbd.d.ts.map +1 -1
- package/dist/Link.d.ts +1 -1
- package/dist/Link.d.ts.map +1 -1
- package/dist/MembersDialog.d.ts +57 -0
- package/dist/MembersDialog.d.ts.map +1 -0
- package/dist/MembersPill.d.ts +22 -0
- package/dist/MembersPill.d.ts.map +1 -0
- package/dist/NavItem.d.ts +1 -1
- package/dist/NavItem.d.ts.map +1 -1
- package/dist/RailItem.d.ts +1 -1
- package/dist/RailItem.d.ts.map +1 -1
- package/dist/SearchInput.d.ts +3 -3
- package/dist/Tabs.d.ts +2 -0
- package/dist/Tabs.d.ts.map +1 -1
- package/dist/TopBar.d.ts +14 -3
- package/dist/TopBar.d.ts.map +1 -1
- package/dist/editor.js +4 -4
- package/dist/editor.js.map +2 -2
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1332 -1143
- package/dist/index.js.map +4 -4
- package/dist/looks.d.ts +2 -0
- package/dist/looks.d.ts.map +1 -1
- package/dist/registry/{app-LDGOH6UW.js → app-X2GI4WTU.js} +2 -2
- package/dist/registry/app.d.ts.map +1 -1
- package/dist/registry/{chunk-TQ6UQER3.js → chunk-V67F474L.js} +8 -3
- package/dist/registry/chunk-V67F474L.js.map +7 -0
- package/dist/registry/cli.js +1 -1
- package/dist/registry/index.js +1 -1
- package/package.json +1 -1
- package/registry.json +234 -14
- package/skill/estiva-ui.md +2 -1
- package/src/AppProvider.mdx +69 -0
- package/src/AppProvider.stories.tsx +60 -0
- package/src/AppProvider.test.tsx +152 -0
- package/src/AppProvider.tsx +76 -0
- package/src/AppShell.mdx +4 -1
- package/src/AppShell.stories.tsx +5 -5
- package/src/AppShell.tsx +7 -3
- package/src/Avatar.tsx +7 -3
- package/src/Breadcrumb.mdx +2 -2
- package/src/Card.mdx +1 -1
- package/src/Chip.mdx +2 -2
- package/src/Chip.tsx +3 -1
- package/src/CommandPalette.tsx +2 -1
- package/src/ComposerTriggers.mdx +7 -0
- package/src/ComposerTriggers.stories.tsx +43 -4
- package/src/ComposerTriggers.test.tsx +33 -0
- package/src/ComposerTriggers.tsx +17 -5
- package/src/IconButton.mdx +2 -2
- package/src/IconButton.tsx +4 -1
- package/src/InlineChip.mdx +3 -3
- package/src/InlineChip.tsx +3 -1
- package/src/Kbd.tsx +4 -2
- package/src/Link.mdx +5 -3
- package/src/Link.tsx +4 -1
- package/src/MembersDialog.mdx +69 -0
- package/src/MembersDialog.stories.tsx +68 -0
- package/src/MembersDialog.test.tsx +69 -0
- package/src/MembersDialog.tsx +184 -0
- package/src/MembersPill.mdx +45 -0
- package/src/MembersPill.stories.tsx +38 -0
- package/src/MembersPill.tsx +35 -0
- package/src/NavItem.mdx +3 -3
- package/src/NavItem.tsx +4 -0
- package/src/NavTree.mdx +3 -2
- package/src/RailItem.mdx +3 -3
- package/src/RailItem.tsx +4 -1
- package/src/SearchInput.mdx +3 -4
- package/src/SearchInput.tsx +3 -3
- package/src/Sidebar.mdx +2 -2
- package/src/Tabs.mdx +5 -0
- package/src/Tabs.stories.tsx +15 -0
- package/src/Tabs.test.tsx +8 -0
- package/src/Tabs.tsx +12 -0
- package/src/TopBar.mdx +18 -9
- package/src/TopBar.stories.tsx +22 -4
- package/src/TopBar.test.tsx +97 -0
- package/src/TopBar.tsx +45 -4
- package/src/index.ts +3 -0
- package/src/looks.ts +3 -0
- package/src/pages.test.ts +3 -1
- package/src/registry/app.test.ts +24 -6
- package/src/registry/app.ts +21 -2
- package/src/registry/contract.test.ts +1 -1
- package/src/registry/gated.test.ts +1 -0
- package/src/registry/registry.test.ts +4 -4
- package/stories/Choosing.mdx +4 -0
- package/dist/registry/chunk-TQ6UQER3.js.map +0 -7
- /package/dist/registry/{app-LDGOH6UW.js.map → app-X2GI4WTU.js.map} +0 -0
|
@@ -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:
|
|
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])
|
package/src/ComposerTriggers.tsx
CHANGED
|
@@ -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
|
|
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/IconButton.mdx
CHANGED
|
@@ -55,8 +55,8 @@ import { IconPencil } from '@tabler/icons-react'
|
|
|
55
55
|
and shows the reason in place of the tooltip — **on keyboard focus as
|
|
56
56
|
well as on hover**.
|
|
57
57
|
- **`href`** makes it a link that looks like the button: an anchor, so the
|
|
58
|
-
address stays real for a modified click or a new tab.
|
|
59
|
-
|
|
58
|
+
address stays real for a modified click or a new tab. It changes page as
|
|
59
|
+
**Link** does. While it cannot be used it
|
|
60
60
|
is the button — a link cannot be disabled.
|
|
61
61
|
- `tooltip` costs no wrapper: the button is the tooltip's trigger, so
|
|
62
62
|
this component's root is the `<button>` whether it carries one or not,
|
package/src/IconButton.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ComponentPropsWithRef, ReactNode } from 'react'
|
|
2
2
|
import { Button as BaseButton } from '@base-ui/react/button'
|
|
3
|
+
import { useInAppClick } from './AppProvider'
|
|
3
4
|
import { cn } from './cn'
|
|
4
5
|
import { MUTED_CONTROL_CLASSES } from './looks'
|
|
5
6
|
import { useFormBusy } from './formBusy'
|
|
@@ -67,6 +68,8 @@ export function IconButton({
|
|
|
67
68
|
}: IconButtonProps) {
|
|
68
69
|
// Inside a busy Form: switched off, and looking it (formBusy.ts).
|
|
69
70
|
const formBusy = useFormBusy()
|
|
71
|
+
const anchor = props as unknown as ComponentPropsWithRef<'a'>
|
|
72
|
+
const inApp = useInAppClick(href, anchor.onClick)
|
|
70
73
|
const unusable = disabled || !!disabledReason || formBusy
|
|
71
74
|
const look = (off: boolean) =>
|
|
72
75
|
cn(
|
|
@@ -104,7 +107,7 @@ export function IconButton({
|
|
|
104
107
|
)
|
|
105
108
|
const button =
|
|
106
109
|
href && !unusable ? (
|
|
107
|
-
<a href={href} className={look(false)} {...
|
|
110
|
+
<a href={href} className={look(false)} {...anchor} onClick={inApp}>
|
|
108
111
|
{children}
|
|
109
112
|
</a>
|
|
110
113
|
) : (
|
package/src/InlineChip.mdx
CHANGED
|
@@ -42,9 +42,9 @@ import { InlineChip } from '@estiva-app/ui'
|
|
|
42
42
|
- It belongs inside running text at the body size. `h-[1.4em]` is one line of
|
|
43
43
|
that text, and `align-top` pins the chip to the line, so a line with a chip
|
|
44
44
|
is the same height as a line without one.
|
|
45
|
-
- It renders a plain anchor when it has an `href
|
|
46
|
-
|
|
47
|
-
address for a modified click or a new tab. Inside something that opens on
|
|
45
|
+
- It renders a plain anchor when it has an `href`, and changes page as
|
|
46
|
+
**Link** does: through **AppProvider**'s `navigate`, or a router app's `onClick`.
|
|
47
|
+
The `href` stays a real address for a modified click or a new tab. Inside something that opens on
|
|
48
48
|
click, stop the event there so the chip's click is not also the container's.
|
|
49
49
|
- A long label: pass `className="max-w-[24ch]"` and wrap the text in
|
|
50
50
|
`<span className="truncate">`.
|
package/src/InlineChip.tsx
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ComponentPropsWithRef, ReactNode } from 'react'
|
|
2
|
+
import { useInAppClick } from './AppProvider'
|
|
2
3
|
import { cn } from './cn'
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -67,6 +68,7 @@ export interface InlineChipProps extends Omit<ComponentPropsWithRef<'a'>, 'child
|
|
|
67
68
|
}
|
|
68
69
|
|
|
69
70
|
export function InlineChip({ tone = 'neutral', icon, href, className, children, ...props }: InlineChipProps) {
|
|
71
|
+
const inApp = useInAppClick(href, props.onClick)
|
|
70
72
|
const body = (
|
|
71
73
|
<>
|
|
72
74
|
{icon && <span className="flex size-4 shrink-0 items-center justify-center text-text-secondary">{icon}</span>}
|
|
@@ -83,7 +85,7 @@ export function InlineChip({ tone = 'neutral', icon, href, className, children,
|
|
|
83
85
|
)
|
|
84
86
|
}
|
|
85
87
|
return (
|
|
86
|
-
<a href={href} className={classes} {...props}>
|
|
88
|
+
<a href={href} className={classes} {...props} onClick={inApp}>
|
|
87
89
|
{body}
|
|
88
90
|
</a>
|
|
89
91
|
)
|
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
|
|
40
|
-
|
|
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',
|
package/src/Link.mdx
CHANGED
|
@@ -60,9 +60,11 @@ import { Link } from '@estiva-app/ui'
|
|
|
60
60
|
</h3>
|
|
61
61
|
```
|
|
62
62
|
|
|
63
|
-
- It renders a plain anchor and passes every anchor prop through.
|
|
64
|
-
|
|
65
|
-
|
|
63
|
+
- It renders a plain anchor and passes every anchor prop through. Under
|
|
64
|
+
**AppProvider**'s `navigate`, a plain click on an address in the app changes page
|
|
65
|
+
without a reload; without it, a router app passes `onClick`, prevents the
|
|
66
|
+
default and navigates. The `href` stays a real address, so a modified click
|
|
67
|
+
or a new tab still works.
|
|
66
68
|
- `quiet` and `underlined` set no size and no colour at rest: put them inside
|
|
67
69
|
the text whose look they should take.
|
|
68
70
|
- `plain` wraps a block: give the Link `className="block"` and let the child
|
package/src/Link.tsx
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ComponentPropsWithRef, ReactNode } from 'react'
|
|
2
|
+
import { useInAppClick } from './AppProvider'
|
|
2
3
|
import { cn } from './cn'
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -70,13 +71,15 @@ export interface LinkProps extends ComponentPropsWithRef<'a'> {
|
|
|
70
71
|
children: ReactNode
|
|
71
72
|
}
|
|
72
73
|
|
|
73
|
-
export function Link({ href, variant = 'text', external = false, truncate = false, cover = false, className, children, ...props }: LinkProps) {
|
|
74
|
+
export function Link({ href, variant = 'text', external = false, truncate = false, cover = false, className, children, onClick, ...props }: LinkProps) {
|
|
75
|
+
const inApp = useInAppClick(href, onClick)
|
|
74
76
|
return (
|
|
75
77
|
<a
|
|
76
78
|
href={href}
|
|
77
79
|
{...(external ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
|
|
78
80
|
className={cn(VARIANT_CLASSES[variant], truncate && 'truncate', cover && COVER_CLASSES, className)}
|
|
79
81
|
{...props}
|
|
82
|
+
onClick={inApp}
|
|
80
83
|
>
|
|
81
84
|
{children}
|
|
82
85
|
</a>
|
|
@@ -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
|
+
})
|