@estiva-app/ui 0.74.1 → 0.75.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 (67) hide show
  1. package/dist/CardUnder.d.ts +10 -0
  2. package/dist/CardUnder.d.ts.map +1 -0
  3. package/dist/InlineChip.d.ts +2 -0
  4. package/dist/InlineChip.d.ts.map +1 -1
  5. package/dist/LinkCard.d.ts +17 -0
  6. package/dist/LinkCard.d.ts.map +1 -0
  7. package/dist/LinkChip.d.ts +8 -0
  8. package/dist/LinkChip.d.ts.map +1 -0
  9. package/dist/LinkParts.d.ts +45 -0
  10. package/dist/LinkParts.d.ts.map +1 -0
  11. package/dist/LinkPreview.d.ts +21 -0
  12. package/dist/LinkPreview.d.ts.map +1 -0
  13. package/dist/LinkRow.d.ts +24 -0
  14. package/dist/LinkRow.d.ts.map +1 -0
  15. package/dist/PartCard.d.ts +32 -0
  16. package/dist/PartCard.d.ts.map +1 -0
  17. package/dist/Skeleton.d.ts +12 -2
  18. package/dist/Skeleton.d.ts.map +1 -1
  19. package/dist/eslint/index.js +1 -0
  20. package/dist/eslint/index.js.map +2 -2
  21. package/dist/eslint/no-restyled-part.d.ts.map +1 -1
  22. package/dist/index.d.ts +8 -0
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +480 -71
  25. package/dist/index.js.map +4 -4
  26. package/dist/linked.d.ts +168 -0
  27. package/dist/linked.d.ts.map +1 -0
  28. package/dist/linked.examples.d.ts +27 -0
  29. package/dist/linked.examples.d.ts.map +1 -0
  30. package/package.json +1 -1
  31. package/registry.json +784 -7
  32. package/src/CardUnder.mdx +53 -0
  33. package/src/CardUnder.stories.tsx +45 -0
  34. package/src/CardUnder.test.tsx +40 -0
  35. package/src/CardUnder.tsx +45 -0
  36. package/src/InlineChip.tsx +2 -0
  37. package/src/LinkCard.mdx +58 -0
  38. package/src/LinkCard.stories.tsx +68 -0
  39. package/src/LinkCard.test.tsx +103 -0
  40. package/src/LinkCard.tsx +209 -0
  41. package/src/LinkChip.mdx +51 -0
  42. package/src/LinkChip.stories.tsx +40 -0
  43. package/src/LinkChip.test.tsx +83 -0
  44. package/src/LinkChip.tsx +51 -0
  45. package/src/LinkParts.mdx +49 -0
  46. package/src/LinkParts.stories.tsx +61 -0
  47. package/src/LinkParts.tsx +147 -0
  48. package/src/LinkPreview.mdx +47 -0
  49. package/src/LinkPreview.stories.tsx +38 -0
  50. package/src/LinkPreview.tsx +55 -0
  51. package/src/LinkRow.mdx +47 -0
  52. package/src/LinkRow.stories.tsx +49 -0
  53. package/src/LinkRow.test.tsx +38 -0
  54. package/src/LinkRow.tsx +107 -0
  55. package/src/PartCard.mdx +43 -0
  56. package/src/PartCard.stories.tsx +35 -0
  57. package/src/PartCard.test.tsx +30 -0
  58. package/src/PartCard.tsx +59 -0
  59. package/src/Skeleton.tsx +7 -3
  60. package/src/eslint/no-native-title.test.ts +3 -2
  61. package/src/eslint/no-restyled-part.ts +1 -0
  62. package/src/index.ts +25 -0
  63. package/src/linked.examples.ts +107 -0
  64. package/src/linked.test.ts +58 -0
  65. package/src/linked.ts +217 -0
  66. package/src/registry/registry.test.ts +8 -8
  67. package/stories/Choosing.mdx +7 -0
@@ -0,0 +1,49 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { GROUP, ITEM, ITEM_UNREAD, LOADING, PAGE, PLACE, ready, SAID, UNAVAILABLE } from './linked.examples'
3
+ import { LinkRow, LinkUnavailable } from './LinkRow'
4
+
5
+ const meta = {
6
+ title: 'Components/LinkRow',
7
+ component: LinkRow,
8
+ parameters: { layout: 'padded' },
9
+ args: { link: ready(ITEM) },
10
+ decorators: [(Story) => <div className="max-w-[480px]">{Story()}</div>],
11
+ } satisfies Meta<typeof LinkRow>
12
+
13
+ export default meta
14
+ type Story = StoryObj<typeof meta>
15
+
16
+ /** A link alone on its line. Hover it for its card. */
17
+ export const Default: Story = {}
18
+
19
+ /** Every kind: the corner says what each one has. */
20
+ export const EachKind: Story = {
21
+ render: () => (
22
+ <div className="flex flex-col gap-2">
23
+ <LinkRow link={ready(ITEM)} />
24
+ <LinkRow link={ready(GROUP)} />
25
+ <LinkRow link={ready(PLACE)} />
26
+ <LinkRow link={ready(PAGE)} />
27
+ <LinkRow link={ready(SAID)} />
28
+ </div>
29
+ ),
30
+ }
31
+
32
+ /** Something unread in its conversation, to a member: the count shows, with its dot. */
33
+ export const Unread: Story = { args: { link: ready(ITEM_UNREAD) } }
34
+
35
+ /** Still being looked up: the row in bars, at the row's height. */
36
+ export const Loading: Story = { args: { link: LOADING } }
37
+
38
+ /** Deleted, or not this reader's to see: one line, and where it may still open. */
39
+ export const NotAvailable: Story = { args: { link: UNAVAILABLE } }
40
+
41
+ /** The not-available line on its own, as a card or a part draws it too; without `openIn`, no way out. */
42
+ export const NotAvailableLine: Story = {
43
+ render: () => (
44
+ <div className="flex flex-col gap-2">
45
+ <LinkUnavailable kind="issue" noun="Item" openIn={{ app: 'App one', href: '#elsewhere' }} />
46
+ <LinkUnavailable kind="page" noun="Page" />
47
+ </div>
48
+ ),
49
+ }
@@ -0,0 +1,38 @@
1
+ // @vitest-environment jsdom
2
+ /** What the LinkRow page claims, pinned: where the row and its count lead, and when the count shows. */
3
+ import { cleanup, render, screen } from '@testing-library/react'
4
+ import { afterEach, describe, expect, it } from 'vitest'
5
+ import { ITEM, ITEM_UNREAD, LOADING, PLACE, ready, UNAVAILABLE } from './linked.examples'
6
+ import { LinkRow } from './LinkRow'
7
+
8
+ afterEach(cleanup)
9
+
10
+ describe('LinkRow', () => {
11
+ it('opens what it links from the whole row', () => {
12
+ render(<LinkRow link={ready(ITEM)} preview={false} />)
13
+ expect(screen.getByRole('link', { name: ITEM.title }).getAttribute('href')).toBe('#item')
14
+ })
15
+
16
+ it('keeps to one line: the count shows only when something in it is unread', () => {
17
+ const { rerender } = render(<LinkRow link={ready(ITEM)} preview={false} />)
18
+ expect(screen.queryByRole('link', { name: /open the conversation/ })).toBeNull()
19
+ rerender(<LinkRow link={ready(ITEM_UNREAD)} preview={false} />)
20
+ expect(screen.getByRole('link', { name: /unread — open the conversation/ }).getAttribute('href')).toBe('#item-conversation')
21
+ })
22
+
23
+ it('always shows the count where conversations are what it holds', () => {
24
+ render(<LinkRow link={ready(PLACE)} preview={false} />)
25
+ expect(screen.getByRole('link', { name: /conversations — open the conversation/ })).toBeTruthy()
26
+ })
27
+
28
+ it('while it loads, is busy and leads nowhere yet', () => {
29
+ const { container } = render(<LinkRow link={LOADING} />)
30
+ expect(container.querySelector('[aria-busy="true"]')).not.toBeNull()
31
+ expect(screen.queryByRole('link')).toBeNull()
32
+ })
33
+
34
+ it('says it is not available in one line', () => {
35
+ render(<LinkRow link={UNAVAILABLE} />)
36
+ expect(screen.getByText('Item — not available.')).toBeTruthy()
37
+ })
38
+ })
@@ -0,0 +1,107 @@
1
+ /**
2
+ * A link alone on its line: one 36px row for every kind. Hovering it shows its
3
+ * card; a tap opens it. Loading draws the row in bars; not available says so
4
+ * in one line of the same height.
5
+ */
6
+ import { Avatar } from './Avatar'
7
+ import { AvatarGroup } from './AvatarGroup'
8
+ import { Card } from './Card'
9
+ import { Link } from './Link'
10
+ import { chosenOf, type LinkedKind, type LinkState } from './linked'
11
+ import { CommentCount, LinkedIcon, LinkKey, ProgressRing, StatusMark } from './LinkParts'
12
+ import { LinkPreview } from './LinkPreview'
13
+ import { SkeletonBar } from './Skeleton'
14
+
15
+ export interface LinkUnavailableProps {
16
+ kind: LinkedKind
17
+ /** What it is, in a word: "Item". Drawn as "Item — not available.". */
18
+ noun: string
19
+ /** Where it may still open, for someone who can: its app and address. */
20
+ openIn?: { app: string; href: string }
21
+ }
22
+
23
+ /**
24
+ * Not available: deleted, or not this reader's to see, which read the same.
25
+ * One line at a row's height, whether a row or a card was to draw. The way
26
+ * out stays, since the app it lives in may still open it for you.
27
+ */
28
+ export function LinkUnavailable({ kind, noun, openIn }: LinkUnavailableProps) {
29
+ return (
30
+ <Card fill="none" unreadable className="flex h-9 items-center gap-2 px-3">
31
+ <LinkedIcon kind={kind} />
32
+ <span className="min-w-0 flex-1 truncate text-body-2 text-text-muted">{noun} — not available.</span>
33
+ {openIn && (
34
+ <span className="shrink-0 text-caption text-text-secondary">
35
+ <Link href={openIn.href} variant="quiet" external>
36
+ Open in {openIn.app} ↗
37
+ </Link>
38
+ </span>
39
+ )}
40
+ </Card>
41
+ )
42
+ }
43
+
44
+ export interface LinkRowProps {
45
+ link: LinkState
46
+ /** Hovering shows its card. */
47
+ preview?: boolean
48
+ }
49
+
50
+ export function LinkRow({ link, preview = true }: LinkRowProps) {
51
+ if (link.state === 'loading') {
52
+ return (
53
+ <div className="animate-skeleton-in" aria-busy>
54
+ <Card fill="inset" aria-hidden className="flex h-9 items-center gap-2 px-3">
55
+ <LinkedIcon kind={link.kind} />
56
+ <SkeletonBar on="inset" className="h-3 w-2/5" />
57
+ <SkeletonBar on="inset" shape="round" className="ml-auto size-4" />
58
+ </Card>
59
+ </div>
60
+ )
61
+ }
62
+ if (link.state === 'unavailable') return <LinkUnavailable kind={link.kind} noun={link.noun} openIn={link.openIn} />
63
+ const { linked } = link
64
+ const status = chosenOf(linked.status)
65
+ const owner = chosenOf(linked.owner)
66
+ const said = linked.kind === 'message' && linked.author
67
+ const row = (
68
+ <Card fill="inset" hover="hairline" className="relative flex h-9 items-center gap-2 px-3">
69
+ {said ? <Avatar name={said.name} src={said.picture} size={16} /> : <LinkedIcon kind={linked.kind} />}
70
+ {said ? (
71
+ <span className="flex min-w-0 flex-1 items-baseline gap-2 text-body-2">
72
+ <span className="shrink-0 text-body-2-strong text-text-primary">{said.name}</span>
73
+ <span className="contents text-text-secondary">
74
+ <Link href={linked.href} variant="quiet" cover truncate>
75
+ {linked.body ?? linked.title}
76
+ </Link>
77
+ </span>
78
+ </span>
79
+ ) : (
80
+ <>
81
+ {linked.key && <LinkKey>{linked.key}</LinkKey>}
82
+ <span className="contents text-body-2-strong text-text-primary">
83
+ <Link href={linked.href} variant="quiet" cover truncate className="min-w-0 flex-1">
84
+ {linked.title}
85
+ </Link>
86
+ </span>
87
+ </>
88
+ )}
89
+ {linked.updated && <span className="shrink-0 text-caption text-text-secondary">{linked.updated}</span>}
90
+ {/* A row keeps to one line: the count shows only when there is something unread in it, except where conversations are what it holds. */}
91
+ {linked.conversation && (linked.conversation.unread || linked.kind === 'topic') && <CommentCount conversation={linked.conversation} noun={linked.conversation.noun} />}
92
+ {/* A ring already says how far along it is. */}
93
+ {status?.colour && !linked.done && <StatusMark status={{ label: status.label, colour: status.colour }} />}
94
+ {linked.done && <ProgressRing done={linked.done.done} total={linked.done.total} />}
95
+ {owner && <Avatar name={owner.label} src={owner.picture} size={16} />}
96
+ {linked.members && <AvatarGroup members={linked.members} size={16} />}
97
+ {linked.authors && <AvatarGroup members={linked.authors} size={16} />}
98
+ </Card>
99
+ )
100
+ return preview ? (
101
+ <LinkPreview linked={linked} block>
102
+ {row}
103
+ </LinkPreview>
104
+ ) : (
105
+ row
106
+ )
107
+ }
@@ -0,0 +1,43 @@
1
+ import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
2
+ import * as PartCardStories from './PartCard.stories'
3
+
4
+ <Meta of={PartCardStories} />
5
+
6
+ # PartCard
7
+
8
+ A part of something longer, shared live: its blocks as they are now, under the header of what it is part of.
9
+
10
+ <Canvas of={PartCardStories.Default} />
11
+
12
+ ## When
13
+
14
+ - **ready** — the icon, the key and the title of what it is part of, as the link, and the blocks the app draws as `children`.
15
+ - **loading** — at a card's height.
16
+ - **unavailable** — one line: "A part of this item — not available.".
17
+
18
+ ## When not
19
+
20
+ - A link to the whole thing → **LinkCard** or **CardUnder**.
21
+
22
+ ## How
23
+
24
+ ```tsx
25
+ import { PartCard } from '@estiva-app/ui'
26
+
27
+ <PartCard state="ready" kind={kind} heading={title} partKey={key} href={hrefAtThePart}>
28
+ {blocksDrawnByTheApp}
29
+ </PartCard>
30
+ ```
31
+
32
+ - The blocks are the app's to draw, with its own renderer.
33
+ - There is no ✕: to send without the part, the writer deletes the link.
34
+
35
+ ## What it owns
36
+
37
+ | It does | So you never write |
38
+ |---|---|
39
+ | Marks the blocks as quoted from elsewhere | a border of your own |
40
+
41
+ ## Props
42
+
43
+ <Controls of={PartCardStories.Default} />
@@ -0,0 +1,35 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { PartCard } from './PartCard'
3
+
4
+ const meta = {
5
+ title: 'Components/PartCard',
6
+ component: PartCard,
7
+ parameters: { layout: 'padded' },
8
+ decorators: [(Story) => <div className="max-w-[480px]">{Story()}</div>],
9
+ } satisfies Meta<typeof PartCard>
10
+
11
+ export default meta
12
+ type Story = StoryObj<typeof meta>
13
+
14
+ /** A part of something longer, under the header of what it is part of. */
15
+ export const Default: Story = {
16
+ args: {
17
+ state: 'ready',
18
+ kind: 'issue',
19
+ heading: 'Item one',
20
+ partKey: 'ABC-2',
21
+ href: '#part',
22
+ children: (
23
+ <>
24
+ <p className="text-body-2-strong text-text-primary">A heading in the part</p>
25
+ <p className="text-body-2 text-text-secondary">The blocks of the part, as they are now. They change when the text they came from changes.</p>
26
+ </>
27
+ ),
28
+ },
29
+ }
30
+
31
+ /** Still being looked up. */
32
+ export const Loading: Story = { args: { state: 'loading', kind: 'issue' } }
33
+
34
+ /** The part, or what it is part of, is gone or not this reader's to see. */
35
+ export const NotAvailable: Story = { args: { state: 'unavailable', kind: 'issue', noun: 'Item', openIn: { app: 'App one', href: '#elsewhere' } } }
@@ -0,0 +1,30 @@
1
+ // @vitest-environment jsdom
2
+ /** What the PartCard page claims, pinned: the header opens what it is part of, the blocks draw, and the states say so. */
3
+ import { cleanup, render, screen } from '@testing-library/react'
4
+ import { afterEach, describe, expect, it } from 'vitest'
5
+ import { PartCard } from './PartCard'
6
+
7
+ afterEach(cleanup)
8
+
9
+ describe('PartCard', () => {
10
+ it('opens what it is part of from its header, and draws the blocks it is given', () => {
11
+ render(
12
+ <PartCard kind="issue" heading="Item one" partKey="ABC-2" href="#part">
13
+ <p>The blocks</p>
14
+ </PartCard>,
15
+ )
16
+ expect(screen.getByRole('link', { name: /ABC-2.*Item one/ }).getAttribute('href')).toBe('#part')
17
+ expect(screen.getByText('The blocks')).toBeTruthy()
18
+ })
19
+
20
+ it('says a part is not available in words', () => {
21
+ render(<PartCard state="unavailable" kind="issue" noun="Item" />)
22
+ expect(screen.getByText('A part of this item — not available.')).toBeTruthy()
23
+ })
24
+
25
+ it('while it loads, is busy and shows nothing of it', () => {
26
+ const { container } = render(<PartCard state="loading" kind="issue" />)
27
+ expect(container.querySelector('[aria-busy="true"]')).not.toBeNull()
28
+ expect(screen.queryByRole('link')).toBeNull()
29
+ })
30
+ })
@@ -0,0 +1,59 @@
1
+ /**
2
+ * A part of something longer, shared live: its blocks as they are now, under
3
+ * the header of what it is part of (why:
4
+ * https://github.com/estiva-app/estiva-docs/blob/main/decisions/a-shared-part-of-a-page-is-a-selected-run-of-blocks.md).
5
+ * There is no row or card choice for a part: it draws itself. The app draws
6
+ * the blocks and passes them as `children`.
7
+ */
8
+ import type { ReactNode } from 'react'
9
+ import { Card } from './Card'
10
+ import { Link } from './Link'
11
+ import { LinkCardLoading } from './LinkCard'
12
+ import type { LinkedKind } from './linked'
13
+ import { LinkedIcon, LinkKey } from './LinkParts'
14
+ import { LinkUnavailable } from './LinkRow'
15
+
16
+ export interface PartCardProps {
17
+ /** Where its lookup stands. Default `ready`. */
18
+ state?: 'ready' | 'loading' | 'unavailable'
19
+ /** What it is part of, by its icon. */
20
+ kind: LinkedKind
21
+ /** The title of what it is part of. */
22
+ heading?: string
23
+ /** Opens what it is part of, at the part. */
24
+ href?: string
25
+ /** The key of what it is part of: "ABC-2". */
26
+ partKey?: string
27
+ /** What it is part of, in a word, for "A part of this item — not available.". */
28
+ noun?: string
29
+ /** Where it may still open, for someone who can, when it is not available. */
30
+ openIn?: { app: string; href: string }
31
+ /** The part's blocks, drawn by the app. */
32
+ children?: ReactNode
33
+ }
34
+
35
+ export function PartCard({ state = 'ready', kind, heading, href, partKey, noun = 'Item', openIn, children }: PartCardProps) {
36
+ if (state === 'loading') return <LinkCardLoading kind={kind} shape={{ summary: true, rows: 0, footer: 'none' }} />
37
+ if (state === 'unavailable') return <LinkUnavailable kind={kind} noun={`A part of this ${noun.toLowerCase()}`} openIn={openIn} />
38
+ const header = (
39
+ <>
40
+ <LinkedIcon kind={kind} />
41
+ {partKey && <LinkKey>{partKey}</LinkKey>}
42
+ <span className="truncate">{heading}</span>
43
+ </>
44
+ )
45
+ return (
46
+ <Card fill="inset" className="flex flex-col gap-2 p-3">
47
+ <span className="flex min-w-0 items-center gap-2 text-body-2-strong text-text-primary">
48
+ {href ? (
49
+ <Link href={href} variant="quiet" className="flex min-w-0 items-center gap-2">
50
+ {header}
51
+ </Link>
52
+ ) : (
53
+ header
54
+ )}
55
+ </span>
56
+ <div className="border-l-2 border-border-default pl-3">{children}</div>
57
+ </Card>
58
+ )
59
+ }
package/src/Skeleton.tsx CHANGED
@@ -10,10 +10,14 @@ import { cn } from './cn'
10
10
  * A list reveals after 150ms (`animate-skeleton-in`, in the preset) so a
11
11
  * fast load never flashes a skeleton — Peek's rule.
12
12
  */
13
+ const SKELETON_SHAPE = { bar: 'rounded-sm', round: 'rounded-full', control: 'rounded-md' } as const
14
+
13
15
  /** One pulsing bar at the inset colour: the piece every placeholder here is
14
- * built from. It draws nothing of its own — give it a width and a height. */
15
- export function SkeletonBar({ className, style }: { className?: string; style?: CSSProperties }) {
16
- return <div className={cn('bg-bg-inset rounded-sm animate-pulse', className)} style={style} />
16
+ * built from. It draws nothing of its own — give it a width and a height.
17
+ * `on="inset"` on an inset card, where the inset colour would vanish;
18
+ * `shape` for a line of words, a face or a ring, or a control. */
19
+ export function SkeletonBar({ className, style, on = 'surface', shape = 'bar' }: { className?: string; style?: CSSProperties; on?: 'surface' | 'inset'; shape?: keyof typeof SKELETON_SHAPE }) {
20
+ return <div className={cn('animate-pulse', SKELETON_SHAPE[shape], on === 'inset' ? 'bg-bg-active' : 'bg-bg-inset', className)} style={style} />
17
21
  }
18
22
 
19
23
  const ROW_BAR_WIDTHS = [150, 100, 170, 120, 90, 140, 110, 160]
@@ -60,8 +60,9 @@ tester.run('no-native-title', noNativeTitle, {
60
60
 
61
61
  describe('PARTS_WITH_A_TITLE', () => {
62
62
  it('is every part the catalogue says takes a title of its own, and no other', () => {
63
- const registry = JSON.parse(readFileSync(new URL('../../registry.json', import.meta.url), 'utf8')) as { entries: { name: string; props?: { name: string }[] | null }[] }
64
- const declared = registry.entries.filter((entry) => entry.props?.some((prop) => prop.name === 'title')).map((entry) => entry.name).sort()
63
+ const registry = JSON.parse(readFileSync(new URL('../../registry.json', import.meta.url), 'utf8')) as { entries: { name: string; kind: string; props?: { name: string }[] | null }[] }
64
+ // A part is a component: a helper whose argument has a `title` field (`loadingShapeOf`) is not drawn with one.
65
+ const declared = registry.entries.filter((entry) => entry.kind === 'component' && entry.props?.some((prop) => prop.name === 'title')).map((entry) => entry.name).sort()
65
66
  expect([...PARTS_WITH_A_TITLE].sort()).toEqual(declared)
66
67
  })
67
68
  })
@@ -132,6 +132,7 @@ export const PART_LOOK_PROPS: Record<string, string[]> = {
132
132
  Checkbox: ['row'],
133
133
  Chip: ['type'],
134
134
  KindIcon: ['tone'],
135
+ LinkedIcon: ['tone'],
135
136
  CollapsibleSection: ['look'],
136
137
  Divider: ['tone', 'orientation'],
137
138
  EmptyState: ['scope'],
package/src/index.ts CHANGED
@@ -84,6 +84,31 @@ export { ShowMore, type ShowMoreProps } from './ShowMore'
84
84
  export { ReactionPicker, type ReactionOption, type ReactionPickerProps } from './ReactionPicker'
85
85
  export { Toolbar, ToolbarButton, ToolbarInput, ToolbarLink, ToolbarSeparator, type ToolbarProps, type ToolbarButtonProps, type ToolbarInputProps, type ToolbarLinkProps } from './Toolbar'
86
86
  export { PreviewCard, type PreviewCardProps } from './PreviewCard'
87
+ // How a link draws, wherever it sits: one kit for every app (Widgets 2).
88
+ export {
89
+ chosenOf,
90
+ loadingShapeOf,
91
+ unavailableMemory,
92
+ type Linked,
93
+ type LinkedChild,
94
+ type LinkedConversation,
95
+ type LinkedKind,
96
+ type LinkedPerson,
97
+ type LinkedPicker,
98
+ type LinkedStatus,
99
+ type LinkState,
100
+ type LoadingShape,
101
+ type PickerOption,
102
+ type StatusColour,
103
+ type UnavailableMemory,
104
+ } from './linked'
105
+ export { CommentCount, LinkedIcon, LinkKey, LinkPickers, ProgressRing, StatusMark, type LinkPickersProps } from './LinkParts'
106
+ export { LinkChip, type LinkChipProps } from './LinkChip'
107
+ export { LinkRow, LinkUnavailable, type LinkRowProps, type LinkUnavailableProps } from './LinkRow'
108
+ export { LinkCard, LinkCardLoading, type LinkCardProps, type LinkCardLoadingProps } from './LinkCard'
109
+ export { LinkPreview, LinkPreviewOpen, useCanHover, type LinkPreviewProps } from './LinkPreview'
110
+ export { CardUnder, type CardUnderProps } from './CardUnder'
111
+ export { PartCard, type PartCardProps } from './PartCard'
87
112
  export { Person, type PersonProps } from './Person'
88
113
  export { Rail, type RailProps } from './Rail'
89
114
  export { RailItem, type RailItemProps } from './RailItem'
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Placeholder links for the link widgets' stories and tests: neutral words,
3
+ * every field one of the widgets draws. Not exported from the package.
4
+ */
5
+ import type { Linked, LinkedPicker, LinkState, PickerOption } from './linked'
6
+
7
+ const noop = () => {}
8
+
9
+ export const STATUS_OPTIONS: PickerOption[] = [
10
+ { value: 'todo', label: 'Todo', colour: 'neutral' },
11
+ { value: 'doing', label: 'In progress', colour: 'blue' },
12
+ { value: 'done', label: 'Done', colour: 'green' },
13
+ { value: 'dropped', label: 'Cancelled', colour: 'muted' },
14
+ ]
15
+
16
+ export const PEOPLE: PickerOption[] = [
17
+ { value: 'a', label: 'Person one' },
18
+ { value: 'b', label: 'Person two' },
19
+ { value: 'c', label: 'Person three' },
20
+ ]
21
+
22
+ const picker = (value: string | undefined, options: PickerOption[], mayChange = true): LinkedPicker => ({ value, options, onChange: mayChange ? noop : undefined })
23
+
24
+ const SUMMARY = 'A one-sentence summary of what this is for.'
25
+
26
+ /** A tracked item: a key, a status and an owner the reader may change, and a conversation. */
27
+ export const ITEM: Linked = {
28
+ id: 'example:item',
29
+ kind: 'issue',
30
+ title: 'Item one, with a title long enough to be cut short',
31
+ href: '#item',
32
+ key: 'ABC-2',
33
+ subtitle: 'Group one',
34
+ summary: SUMMARY,
35
+ status: picker('doing', STATUS_OPTIONS),
36
+ owner: picker('a', PEOPLE),
37
+ ownerLabel: 'Owner',
38
+ conversation: { count: 4, href: '#item-conversation' },
39
+ }
40
+
41
+ /** The same item, to a reader who may not change it. */
42
+ export const ITEM_READ_ONLY: Linked = { ...ITEM, status: picker('doing', STATUS_OPTIONS, false), owner: picker('a', PEOPLE, false) }
43
+
44
+ /** The same item with something unread in its conversation, as a member sees it. */
45
+ export const ITEM_UNREAD: Linked = { ...ITEM, conversation: { count: 5, href: '#item-conversation', unread: true } }
46
+
47
+ /** An item with no summary: its card shows the title alone. */
48
+ export const ITEM_NO_SUMMARY: Linked = { ...ITEM, id: 'example:item-2', title: 'Item two', key: 'ABC-3', summary: undefined, owner: picker(undefined, PEOPLE) }
49
+
50
+ /** A group of items: its progress, its owner, and the items inside it. */
51
+ export const GROUP: Linked = {
52
+ id: 'example:group',
53
+ kind: 'project',
54
+ title: 'Group one',
55
+ href: '#group',
56
+ summary: SUMMARY,
57
+ status: picker('doing', STATUS_OPTIONS),
58
+ owner: picker('b', PEOPLE),
59
+ ownerLabel: 'Lead',
60
+ done: { done: 2, total: 5 },
61
+ conversation: { count: 2, href: '#group-conversation' },
62
+ children: [
63
+ { id: 'example:c1', title: 'Item one', href: '#c1', key: 'ABC-1', status: { label: 'Done', colour: 'green' }, owner: { name: 'Person one' } },
64
+ { id: 'example:c2', title: 'Item two', href: '#c2', key: 'ABC-2', status: { label: 'In progress', colour: 'blue' }, owner: { name: 'Person two' } },
65
+ { id: 'example:c3', title: 'Item three', href: '#c3', key: 'ABC-3', status: { label: 'Todo', colour: 'neutral' } },
66
+ ],
67
+ }
68
+
69
+ /** A place people talk: its members, when it last moved, and its newest conversation. */
70
+ export const PLACE: Linked = {
71
+ id: 'example:place',
72
+ kind: 'topic',
73
+ title: 'Place one',
74
+ href: '#place',
75
+ summary: SUMMARY,
76
+ members: [{ name: 'Person one' }, { name: 'Person two' }, { name: 'Person three' }],
77
+ updated: '2h ago',
78
+ conversation: { count: 12, href: '#place-conversations', noun: 'conversations' },
79
+ latest: { author: { name: 'Person two' }, body: 'A few words someone wrote, long enough to need a second line before the card cuts them short.', at: '2h ago' },
80
+ }
81
+
82
+ /** A page of text: who wrote it, and when it last changed. */
83
+ export const PAGE: Linked = {
84
+ id: 'example:page',
85
+ kind: 'page',
86
+ title: 'Page one',
87
+ href: '#page',
88
+ subtitle: 'Shelf one',
89
+ summary: SUMMARY,
90
+ authors: [{ name: 'Person one' }, { name: 'Person three' }],
91
+ updated: 'Yesterday',
92
+ }
93
+
94
+ /** Words a person wrote, linked as themselves. */
95
+ export const SAID: Linked = {
96
+ id: 'example:said',
97
+ kind: 'message',
98
+ title: 'A few words someone wrote',
99
+ href: '#said',
100
+ author: { name: 'Person one' },
101
+ body: 'A few words someone wrote, linked from somewhere else. Long enough to wrap onto a second line.',
102
+ updated: '10:42',
103
+ }
104
+
105
+ export const ready = (linked: Linked): LinkState => ({ state: 'ready', linked })
106
+ export const LOADING: LinkState = { state: 'loading', kind: 'issue' }
107
+ export const UNAVAILABLE: LinkState = { state: 'unavailable', kind: 'issue', noun: 'Item', openIn: { app: 'App one', href: '#elsewhere' } }
@@ -0,0 +1,58 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * The device's memory of links it could not show: kept across a reload, so
4
+ * the next open draws "not available." from the first paint, and forgotten
5
+ * when the link can be read again or at sign-out.
6
+ */
7
+ import { afterEach, describe, expect, it } from 'vitest'
8
+ import { unavailableMemory } from './linked'
9
+
10
+ afterEach(() => localStorage.clear())
11
+
12
+ describe('unavailableMemory', () => {
13
+ it('remembers a link it could not show across a reload', () => {
14
+ unavailableMemory('test.unavailable').note('a', false)
15
+ // A new page: a fresh memory reads what the last one wrote.
16
+ expect(unavailableMemory('test.unavailable').has('a')).toBe(true)
17
+ expect(unavailableMemory('test.unavailable').has('b')).toBe(false)
18
+ })
19
+
20
+ it('forgets it once it can be read again', () => {
21
+ const memory = unavailableMemory('test.unavailable')
22
+ memory.note('a', false)
23
+ memory.note('a', true)
24
+ expect(unavailableMemory('test.unavailable').has('a')).toBe(false)
25
+ })
26
+
27
+ it('keeps what another memory on the same key wrote since it was read', () => {
28
+ const one = unavailableMemory('test.unavailable')
29
+ const two = unavailableMemory('test.unavailable')
30
+ expect(two.has('a')).toBe(false)
31
+ one.note('a', false)
32
+ two.note('b', false)
33
+ const next = unavailableMemory('test.unavailable')
34
+ expect([next.has('a'), next.has('b')]).toEqual([true, true])
35
+ })
36
+
37
+ it('keeps each app’s memory apart', () => {
38
+ unavailableMemory('one.unavailable').note('a', false)
39
+ expect(unavailableMemory('two.unavailable').has('a')).toBe(false)
40
+ })
41
+
42
+ it('forgets everything at sign-out', () => {
43
+ const memory = unavailableMemory('test.unavailable')
44
+ memory.note('a', false)
45
+ memory.clear()
46
+ expect(memory.has('a')).toBe(false)
47
+ expect(unavailableMemory('test.unavailable').has('a')).toBe(false)
48
+ })
49
+
50
+ it('keeps only the newest, up to its limit', () => {
51
+ const memory = unavailableMemory('test.unavailable', 2)
52
+ memory.note('a', false)
53
+ memory.note('b', false)
54
+ memory.note('c', false)
55
+ const next = unavailableMemory('test.unavailable', 2)
56
+ expect([next.has('a'), next.has('b'), next.has('c')]).toEqual([false, true, true])
57
+ })
58
+ })