@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,51 @@
1
+ import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
2
+ import * as LinkChipStories from './LinkChip.stories'
3
+
4
+ <Meta of={LinkChipStories} />
5
+
6
+ # LinkChip
7
+
8
+ A link in a sentence: its icon, its key, its title cut short, and its status as a shape.
9
+
10
+ <Canvas of={LinkChipStories.InASentence} />
11
+
12
+ ## When
13
+
14
+ - **ready** — a link the app has looked up. Hovering it shows its card (**LinkPreview**); a tap or a click opens it.
15
+ - **loading** — still being looked up: the icon, which the address already says, and a bar.
16
+ - **unavailable** — deleted, or not the reader's to see. Both say "Item, not available" in the app's word for it.
17
+ - `preview={false}` — no card on hover, where a card would cover what the reader is choosing from.
18
+
19
+ <Canvas of={LinkChipStories.EachKind} />
20
+
21
+ ## When not
22
+
23
+ - A link alone on its line → **LinkRow**.
24
+ - The one card under a piece of text → **CardUnder**.
25
+ - A person, a place or a label with no lookup behind it → **InlineChip**.
26
+
27
+ ## How
28
+
29
+ ```tsx
30
+ import { LinkChip, type LinkState } from '@estiva-app/ui'
31
+
32
+ // The app looks the link up and hands in where that stands.
33
+ const link: LinkState = found ? { state: 'ready', linked: found } : gone ? { state: 'unavailable', kind: 'issue', noun: 'Item' } : { state: 'loading', kind: 'issue' }
34
+ <LinkChip link={link} />
35
+ ```
36
+
37
+ - **The app looks it up; the chip draws.** `linked` (`Linked`, from `@estiva-app/ui`) carries the words, the `href`, the status and the owner as the app's lookup found them. The chip fetches nothing.
38
+ - `linked.href` opens it. An address in this app goes through `AppProvider`'s `navigate`, so the page does not reload.
39
+ - Words a person wrote (`kind: 'message'` with an `author`) lead with their face and read "Name: words".
40
+
41
+ ## What it owns
42
+
43
+ | It does | So you never write |
44
+ |---|---|
45
+ | Shows the card on hover, and none on a screen that cannot hover | a hover card of your own, a touch check |
46
+ | Cuts a long title short | a truncation of your own |
47
+ | Says "not available" in words, quieter than a link | a missing-reference chip of your own |
48
+
49
+ ## Props
50
+
51
+ <Controls of={LinkChipStories.InASentence} />
@@ -0,0 +1,40 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { LinkChip } from './LinkChip'
3
+ import { GROUP, ITEM, LOADING, PAGE, PLACE, ready, SAID, UNAVAILABLE } from './linked.examples'
4
+
5
+ const meta = {
6
+ title: 'Components/LinkChip',
7
+ component: LinkChip,
8
+ parameters: { layout: 'padded' },
9
+ args: { link: ready(ITEM) },
10
+ } satisfies Meta<typeof LinkChip>
11
+
12
+ export default meta
13
+ type Story = StoryObj<typeof meta>
14
+
15
+ /** In a sentence. Hover it for its card. */
16
+ export const InASentence: Story = {
17
+ render: (args) => (
18
+ <p className="max-w-[560px] text-body-2 text-text-primary">
19
+ A sentence that links <LinkChip {...args} /> in the middle of its words, and goes on after it.
20
+ </p>
21
+ ),
22
+ }
23
+
24
+ /** Every kind, by its icon; words a person wrote lead with their face. */
25
+ export const EachKind: Story = {
26
+ render: () => (
27
+ <p className="max-w-[560px] text-body-2 leading-7 text-text-primary">
28
+ <LinkChip link={ready(ITEM)} /> <LinkChip link={ready(GROUP)} /> <LinkChip link={ready(PLACE)} /> <LinkChip link={ready(PAGE)} /> <LinkChip link={ready(SAID)} />
29
+ </p>
30
+ ),
31
+ }
32
+
33
+ /** Still being looked up: the icon, known from the address, and a bar. */
34
+ export const Loading: Story = { args: { link: LOADING } }
35
+
36
+ /** Deleted, or not this reader's to see: said in words. */
37
+ export const NotAvailable: Story = { args: { link: UNAVAILABLE } }
38
+
39
+ /** No card on hover: where a card would cover what the reader is choosing from. */
40
+ export const WithoutPreview: Story = { args: { preview: false } }
@@ -0,0 +1,83 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * What the LinkChip and LinkPreview pages claim, pinned: what each state says,
4
+ * where the chip leads, and that a screen that cannot hover gets no preview.
5
+ */
6
+ import { act, cleanup, render, screen } from '@testing-library/react'
7
+ import userEvent from '@testing-library/user-event'
8
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
9
+ import { LinkChip } from './LinkChip'
10
+ import { ITEM, LOADING, ready, SAID, UNAVAILABLE } from './linked.examples'
11
+
12
+ /** A screen that can hover, or one that cannot (a phone, a tablet). */
13
+ function screenThatCanHover(canHover: boolean) {
14
+ vi.stubGlobal('matchMedia', (query: string) => ({
15
+ matches: query === '(hover: hover)' ? canHover : false,
16
+ media: query,
17
+ addEventListener: () => {},
18
+ removeEventListener: () => {},
19
+ }))
20
+ }
21
+
22
+ beforeEach(() => {
23
+ vi.useFakeTimers({ shouldAdvanceTime: true })
24
+ })
25
+
26
+ afterEach(() => {
27
+ cleanup()
28
+ vi.useRealTimers()
29
+ vi.unstubAllGlobals()
30
+ })
31
+
32
+ /** Rest the pointer on the chip past the preview's delay. */
33
+ async function hoverChip(name: RegExp) {
34
+ const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
35
+ await user.hover(screen.getByRole('link', { name }))
36
+ await act(async () => {
37
+ vi.advanceTimersByTime(1000)
38
+ })
39
+ }
40
+
41
+ describe('LinkChip', () => {
42
+ it('leads to what it links, with its key and title', () => {
43
+ render(<LinkChip link={ready(ITEM)} preview={false} />)
44
+ const link = screen.getByRole('link', { name: /ABC-2/ })
45
+ expect(link.getAttribute('href')).toBe('#item')
46
+ expect(link.textContent).toContain(ITEM.title)
47
+ })
48
+
49
+ it('says a person’s words after their name', () => {
50
+ render(<LinkChip link={ready(SAID)} preview={false} />)
51
+ expect(screen.getByRole('link').textContent).toContain(`Person one: ${SAID.body}`)
52
+ })
53
+
54
+ it('while it loads, is busy and leads nowhere yet', () => {
55
+ const { container } = render(<LinkChip link={LOADING} />)
56
+ expect(container.querySelector('[aria-busy="true"]')).not.toBeNull()
57
+ expect(screen.queryByRole('link')).toBeNull()
58
+ })
59
+
60
+ it('says “not available” in words, and leads nowhere', () => {
61
+ render(<LinkChip link={UNAVAILABLE} />)
62
+ expect(screen.getByText('Item, not available')).toBeTruthy()
63
+ expect(screen.queryByRole('link')).toBeNull()
64
+ })
65
+
66
+ it('shows its card, read only, when the pointer rests on it', async () => {
67
+ screenThatCanHover(true)
68
+ render(<LinkChip link={ready(ITEM)} />)
69
+ await hoverChip(/ABC-2/)
70
+ expect(screen.getByText(ITEM.summary!)).toBeTruthy()
71
+ // Read only: the values are words, never pickers, though this reader may change them.
72
+ expect(screen.queryByRole('combobox')).toBeNull()
73
+ expect(screen.getByText('Person one')).toBeTruthy()
74
+ })
75
+
76
+ it('shows no card on a screen that cannot hover, where a tap opens it', async () => {
77
+ screenThatCanHover(false)
78
+ render(<LinkChip link={ready(ITEM)} />)
79
+ await hoverChip(/ABC-2/)
80
+ expect(screen.queryByText(ITEM.summary!)).toBeNull()
81
+ expect(screen.getByRole('link', { name: /ABC-2/ }).getAttribute('href')).toBe('#item')
82
+ })
83
+ })
@@ -0,0 +1,51 @@
1
+ /**
2
+ * A link in running text: the icon (or the author's face, for words a person
3
+ * wrote), the key, the title cut short, the status as its shape. Loading
4
+ * keeps the icon, since the kind is known from the address; not available says
5
+ * so in words. Hovering it shows its card; a tap opens it.
6
+ */
7
+ import { Avatar } from './Avatar'
8
+ import { InlineChip } from './InlineChip'
9
+ import type { LinkState } from './linked'
10
+ import { chosenOf } from './linked'
11
+ import { LinkedIcon, LinkKey, StatusMark } from './LinkParts'
12
+ import { LinkPreview } from './LinkPreview'
13
+ import { SkeletonBar } from './Skeleton'
14
+
15
+ export interface LinkChipProps {
16
+ link: LinkState
17
+ /** Hovering shows its card. Off where a card would cover what the reader is choosing from. */
18
+ preview?: boolean
19
+ }
20
+
21
+ export function LinkChip({ link, preview = true }: LinkChipProps) {
22
+ if (link.state === 'loading') {
23
+ return (
24
+ <InlineChip icon={<LinkedIcon kind={link.kind} size={14} tone="inherit" />} aria-busy>
25
+ <SkeletonBar className="h-2.5 w-16" />
26
+ </InlineChip>
27
+ )
28
+ }
29
+ if (link.state === 'unavailable') {
30
+ return (
31
+ <InlineChip tone="muted" icon={<LinkedIcon kind={link.kind} size={14} tone="inherit" />}>
32
+ {link.noun}, not available
33
+ </InlineChip>
34
+ )
35
+ }
36
+ const { linked } = link
37
+ const status = chosenOf(linked.status)
38
+ const said = linked.kind === 'message' && linked.author
39
+ const chip = (
40
+ <InlineChip
41
+ icon={said ? <Avatar name={said.name} src={said.picture} size={16} /> : <LinkedIcon kind={linked.kind} size={14} tone="inherit" />}
42
+ href={linked.href}
43
+ className="max-w-[28ch]"
44
+ >
45
+ {linked.key && <LinkKey>{linked.key}</LinkKey>}
46
+ <span className="truncate">{said ? `${said.name}: ${linked.body ?? linked.title}` : linked.title}</span>
47
+ {status?.colour && <StatusMark status={{ label: status.label, colour: status.colour }} />}
48
+ </InlineChip>
49
+ )
50
+ return preview ? <LinkPreview linked={linked}>{chip}</LinkPreview> : chip
51
+ }
@@ -0,0 +1,49 @@
1
+ import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
2
+ import * as LinkPartsStories from './LinkParts.stories'
3
+
4
+ <Meta of={LinkPartsStories} />
5
+
6
+ # LinkParts
7
+
8
+ The small parts the link widgets are built from: the pickers, the conversation count, a status's shape and a progress ring.
9
+
10
+ <Canvas of={LinkPartsStories.Pickers} />
11
+
12
+ ## When
13
+
14
+ - **LinkPickers** — a status and an owner, changed in place. A picker with no `onChange`, or `readOnly`, reads as words in the same place.
15
+ - **CommentCount** — how much was said, as a link to where it is read; brighter, with a dot, when there is something unread for a member.
16
+ - **StatusMark** — a status as its shape, which reads without colour; its words on hover.
17
+ - **ProgressRing** — how far along, as a ring and a count.
18
+ - **LinkedIcon**, **LinkKey** — the icon for what a link stands for, and its key.
19
+
20
+ <Canvas of={LinkPartsStories.PickersReadOnly} />
21
+ <Canvas of={LinkPartsStories.Conversation} />
22
+ <Canvas of={LinkPartsStories.Marks} />
23
+
24
+ ## When not
25
+
26
+ - A whole link → **LinkChip**, **LinkRow**, **LinkCard**.
27
+ - A choice in a form → **Select**.
28
+
29
+ ## How
30
+
31
+ ```tsx
32
+ import { LinkPickers, CommentCount } from '@estiva-app/ui'
33
+
34
+ <LinkPickers status={{ value: statusKey, options: statuses, onChange: setStatus }} owner={{ value: ownerKey, options: people }} ownerLabel="Owner" />
35
+ <CommentCount conversation={{ count: 4, href: conversationHref, unread: isMemberWithUnread }} />
36
+ ```
37
+
38
+ - `unread` only for a member of the conversation: anyone else sees the count alone.
39
+
40
+ ## What it owns
41
+
42
+ | It does | So you never write |
43
+ |---|---|
44
+ | Draws a value as words where it may not be changed | a disabled select |
45
+ | Says the count and whether it is unread to a screen reader | an `aria-label` of your own |
46
+
47
+ ## Props
48
+
49
+ <Controls of={LinkPartsStories.PickersReadOnly} />
@@ -0,0 +1,61 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { useState } from 'react'
3
+ import { PEOPLE, STATUS_OPTIONS } from './linked.examples'
4
+ import { CommentCount, LinkPickers, ProgressRing, StatusMark } from './LinkParts'
5
+
6
+ const meta = {
7
+ title: 'Components/LinkParts',
8
+ component: LinkPickers,
9
+ parameters: { layout: 'padded' },
10
+ } satisfies Meta<typeof LinkPickers>
11
+
12
+ export default meta
13
+ type Story = StoryObj<typeof meta>
14
+
15
+ /** A status and an owner, changed from the card. */
16
+ export const Pickers: Story = {
17
+ render: () => {
18
+ const [status, setStatus] = useState('todo')
19
+ const [owner, setOwner] = useState<string | undefined>(undefined)
20
+ return (
21
+ <LinkPickers
22
+ status={{ value: status, options: STATUS_OPTIONS, onChange: setStatus }}
23
+ owner={{ value: owner, options: PEOPLE, onChange: setOwner }}
24
+ ownerLabel="Owner"
25
+ />
26
+ )
27
+ },
28
+ }
29
+
30
+ /** To a reader who may not change them, and in the hover preview: the same values, as words. */
31
+ export const PickersReadOnly: Story = {
32
+ args: { status: { value: 'doing', options: STATUS_OPTIONS }, owner: { value: 'b', options: PEOPLE } },
33
+ }
34
+
35
+ /** Nobody owns it yet, read only. */
36
+ export const NoOwner: Story = {
37
+ args: { status: { value: 'todo', options: STATUS_OPTIONS }, owner: { value: undefined, options: PEOPLE } },
38
+ }
39
+
40
+ /** The count of what was said, opening where it is read. With something unread, to a member: brighter, with a dot. */
41
+ export const Conversation: Story = {
42
+ render: () => (
43
+ <div className="flex items-center gap-4">
44
+ <CommentCount conversation={{ count: 4, href: '#conversation' }} />
45
+ <CommentCount conversation={{ count: 5, href: '#conversation', unread: true }} />
46
+ </div>
47
+ ),
48
+ }
49
+
50
+ /** A status as its shape, and progress as a ring. */
51
+ export const Marks: Story = {
52
+ render: () => (
53
+ <div className="flex items-center gap-4">
54
+ {STATUS_OPTIONS.map((option) => (
55
+ <StatusMark key={option.value} status={{ label: option.label, colour: option.colour! }} />
56
+ ))}
57
+ <ProgressRing done={2} total={5} />
58
+ <ProgressRing done={5} total={5} />
59
+ </div>
60
+ ),
61
+ }
@@ -0,0 +1,147 @@
1
+ /**
2
+ * The small parts every link widget is built from: the icon for what a link
3
+ * stands for, a status as its shape, progress as a ring, the conversation
4
+ * count with its unread dot, and the pickers. LinkChip, LinkRow and LinkCard
5
+ * place them; an app may place them too.
6
+ */
7
+ import { IconCircle, IconCircleCheck, IconCircleDashed, IconCircleHalf2, IconCircleX, IconFileText, IconMessage2 } from '@tabler/icons-react'
8
+ import { Avatar } from './Avatar'
9
+ import { cn } from './cn'
10
+ import { KindIcon } from './KindIcon'
11
+ import { Link } from './Link'
12
+ import { chosenOf, type LinkedConversation, type LinkedKind, type LinkedPicker, type LinkedStatus, type StatusColour } from './linked'
13
+ import { Select } from './Select'
14
+ import { WithTooltip } from './Tooltip'
15
+ import { UnreadDot } from './UnreadDot'
16
+
17
+ const STATUS_ICON: Record<StatusColour, { Icon: typeof IconCircle; tone: string }> = {
18
+ neutral: { Icon: IconCircleDashed, tone: 'text-text-secondary' },
19
+ blue: { Icon: IconCircleHalf2, tone: 'text-info-default' },
20
+ green: { Icon: IconCircleCheck, tone: 'text-success-default' },
21
+ muted: { Icon: IconCircleX, tone: 'text-text-muted' },
22
+ }
23
+
24
+ /** A status's shape alone, beside words that already name it. */
25
+ function StatusIcon({ colour }: { colour: StatusColour }) {
26
+ const { Icon, tone } = STATUS_ICON[colour]
27
+ return <Icon size={14} stroke={1.5} className={cn('shrink-0', tone)} aria-hidden />
28
+ }
29
+
30
+ /** A status as its shape, which reads without colour; its words on hover and to a screen reader. */
31
+ export function StatusMark({ status }: { status: LinkedStatus }) {
32
+ return (
33
+ <WithTooltip label={status.label} inline wrapperClassName="inline-flex shrink-0 items-center">
34
+ <StatusIcon colour={status.colour} />
35
+ <span className="sr-only">{status.label}</span>
36
+ </WithTooltip>
37
+ )
38
+ }
39
+
40
+ /** The icon for what a link stands for. A page of text has its own. */
41
+ export function LinkedIcon({ kind, size = 16, tone = 'secondary' }: { kind: LinkedKind; size?: 14 | 16; tone?: 'secondary' | 'inherit' }) {
42
+ if (kind === 'page') return <IconFileText size={size} stroke={1.5} className={cn('shrink-0', tone === 'secondary' && 'text-text-secondary')} aria-hidden />
43
+ return <KindIcon kind={kind} size={size} tone={tone} />
44
+ }
45
+
46
+ /** A key, the way people say it ("ABC-2"): before the title, quieter than it. */
47
+ export function LinkKey({ children }: { children: string }) {
48
+ return <span className="shrink-0 text-caption tabular-nums text-text-muted">{children}</span>
49
+ }
50
+
51
+ /** Progress as a small ring, with the count beside it. */
52
+ export function ProgressRing({ done, total }: { done: number; total: number }) {
53
+ const size = 16
54
+ const stroke = 2
55
+ const r = (size - stroke) / 2
56
+ const c = 2 * Math.PI * r
57
+ const share = total > 0 ? Math.min(done, total) / total : 0
58
+ return (
59
+ <WithTooltip label={`${done} of ${total} done`} inline wrapperClassName="inline-flex shrink-0">
60
+ <span className="inline-flex shrink-0 items-center gap-1 text-caption tabular-nums text-text-secondary">
61
+ <svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} className="shrink-0 -rotate-90" aria-hidden>
62
+ <circle cx={size / 2} cy={size / 2} r={r} fill="none" strokeWidth={stroke} className="stroke-border-default" />
63
+ <circle cx={size / 2} cy={size / 2} r={r} fill="none" strokeWidth={stroke} strokeLinecap="round" strokeDasharray={`${c * share} ${c}`} className="stroke-success-default" />
64
+ </svg>
65
+ {done}/{total}
66
+ </span>
67
+ </WithTooltip>
68
+ )
69
+ }
70
+
71
+ /**
72
+ * The count of what was said, a link to where it is read. With something
73
+ * unread there the words are brighter and a dot follows. It sits above a
74
+ * row's covering link, so the row opens the thing and the count its
75
+ * conversation.
76
+ */
77
+ export function CommentCount({ conversation, noun = 'comments' }: { conversation: LinkedConversation; noun?: string }) {
78
+ const { count, href, unread = false } = conversation
79
+ return (
80
+ <span className={cn('relative z-10 -mx-1 inline-flex shrink-0 rounded-sm px-1 text-caption tabular-nums hover:bg-bg-active', unread ? 'font-medium text-text-primary' : 'text-text-secondary')}>
81
+ <Link href={href} variant="plain" className="inline-flex items-center gap-1">
82
+ <IconMessage2 size={14} stroke={1.5} aria-hidden />
83
+ {count}
84
+ <span className="sr-only">{unread ? `${noun}, unread — open the conversation` : `${noun} — open the conversation`}</span>
85
+ {unread && <UnreadDot className="h-auto w-auto" />}
86
+ </Link>
87
+ </span>
88
+ )
89
+ }
90
+
91
+ export interface LinkPickersProps {
92
+ status?: LinkedPicker
93
+ owner?: LinkedPicker
94
+ /** The owner's word: "Assignee", "Lead". */
95
+ ownerLabel?: string
96
+ /** Draws the values as words even where they could be changed: the hover preview. */
97
+ readOnly?: boolean
98
+ }
99
+
100
+ /**
101
+ * A status and an owner, where the reader can change them from the card. A
102
+ * picker whose app passed no `onChange` reads as words in the same place: the
103
+ * reader may not change it. The hover preview is always words.
104
+ */
105
+ export function LinkPickers({ status, owner, ownerLabel = 'Owner', readOnly = false }: LinkPickersProps) {
106
+ const statusNow = chosenOf(status)
107
+ const ownerNow = chosenOf(owner)
108
+ const statusWords = status && (readOnly || !status.onChange)
109
+ const ownerWords = owner && (readOnly || !owner.onChange)
110
+ return (
111
+ <span className="flex h-6 min-w-0 items-center gap-2 text-caption text-text-secondary">
112
+ {status &&
113
+ (statusWords ? (
114
+ <span className="inline-flex shrink-0 items-center gap-1">
115
+ {statusNow?.colour && <StatusIcon colour={statusNow.colour} />}
116
+ {statusNow?.label ?? 'No status'}
117
+ </span>
118
+ ) : (
119
+ <Select
120
+ size="small"
121
+ className="w-auto"
122
+ value={status.value ?? ''}
123
+ onChange={(value) => status.onChange?.(value)}
124
+ ariaLabel="Status"
125
+ options={status.options.map((option) => ({ value: option.value, label: option.label, leading: option.colour && <StatusIcon colour={option.colour} /> }))}
126
+ />
127
+ ))}
128
+ {owner &&
129
+ (ownerWords ? (
130
+ <span className="inline-flex min-w-0 items-center gap-1">
131
+ <Avatar name={ownerNow?.label} src={ownerNow?.picture} size={16} />
132
+ <span className="truncate">{ownerNow?.label ?? 'Unassigned'}</span>
133
+ </span>
134
+ ) : (
135
+ <Select
136
+ size="small"
137
+ className="w-auto"
138
+ value={owner.value ?? ''}
139
+ placeholder="Unassigned"
140
+ onChange={(value) => owner.onChange?.(value)}
141
+ ariaLabel={ownerLabel}
142
+ options={owner.options.map((option) => ({ value: option.value, label: option.label, leading: <Avatar name={option.label} src={option.picture} size={16} /> }))}
143
+ />
144
+ ))}
145
+ </span>
146
+ )
147
+ }
@@ -0,0 +1,47 @@
1
+ import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
2
+ import * as LinkPreviewStories from './LinkPreview.stories'
3
+
4
+ <Meta of={LinkPreviewStories} />
5
+
6
+ # LinkPreview
7
+
8
+ A link's card on hover over its chip or its row, read only, in the package's one elevated box.
9
+
10
+ <Canvas of={LinkPreviewStories.OnHover} />
11
+
12
+ ## When
13
+
14
+ - **on hover** — **LinkChip** and **LinkRow** wrap themselves in it; an app wraps its own trigger only when it draws a link some other way.
15
+ - **open** — `LinkPreviewOpen`, the same box drawn open, for a still picture.
16
+ - **on a screen that cannot hover** — nothing: the trigger alone, and a tap opens the link. `useCanHover()` says which.
17
+
18
+ <Canvas of={LinkPreviewStories.Open} />
19
+
20
+ ## When not
21
+
22
+ - A card that stays → **LinkCard**, or **CardUnder** under a piece of text.
23
+ - A preview of something that is not a link → **PreviewCard**.
24
+
25
+ ## How
26
+
27
+ ```tsx
28
+ import { LinkPreview } from '@estiva-app/ui'
29
+
30
+ <LinkPreview linked={linked} block>
31
+ {yourRow}
32
+ </LinkPreview>
33
+ ```
34
+
35
+ - `block` for a trigger that fills its column; a chip is inline.
36
+ - Nothing in it can be changed: the values are words.
37
+
38
+ ## What it owns
39
+
40
+ | It does | So you never write |
41
+ |---|---|
42
+ | Waits before it opens, and closes when the pointer leaves | a hover timer |
43
+ | Draws no preview on a screen that cannot hover | a `matchMedia` check |
44
+
45
+ ## Props
46
+
47
+ <Controls of={LinkPreviewStories.OnHover} />
@@ -0,0 +1,38 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { LinkChip } from './LinkChip'
3
+ import { GROUP, ITEM, PLACE, ready } from './linked.examples'
4
+ import { LinkPreview, LinkPreviewOpen } from './LinkPreview'
5
+ import { LinkRow } from './LinkRow'
6
+
7
+ const meta = {
8
+ title: 'Components/LinkPreview',
9
+ component: LinkPreview,
10
+ parameters: { layout: 'padded' },
11
+ args: { linked: ITEM, children: null },
12
+ } satisfies Meta<typeof LinkPreview>
13
+
14
+ export default meta
15
+ type Story = StoryObj<typeof meta>
16
+
17
+ /** Hover the chip or the row: the card shows, read only. On a screen that cannot hover there is none, and a tap opens the link. */
18
+ export const OnHover: Story = {
19
+ render: () => (
20
+ <div className="flex max-w-[480px] flex-col gap-3">
21
+ <p className="text-body-2 text-text-primary">
22
+ Hover <LinkChip link={ready(ITEM)} /> for its card.
23
+ </p>
24
+ <LinkRow link={ready(GROUP)} />
25
+ </div>
26
+ ),
27
+ }
28
+
29
+ /** Drawn open, for a still picture: the values are words, never pickers. */
30
+ export const Open: Story = {
31
+ render: () => (
32
+ <div className="flex flex-wrap items-start gap-4">
33
+ <LinkPreviewOpen linked={ITEM} />
34
+ <LinkPreviewOpen linked={GROUP} />
35
+ <LinkPreviewOpen linked={PLACE} />
36
+ </div>
37
+ ),
38
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * A link's card, shown on hover over its chip or its row: read only, in the
3
+ * package's one elevated box. A screen that cannot hover draws no preview, and
4
+ * a tap opens the link (Miky, WID-2: "there's no hover on touch").
5
+ */
6
+ import { useSyncExternalStore, type ReactNode } from 'react'
7
+ import { LinkCard } from './LinkCard'
8
+ import type { Linked } from './linked'
9
+ import { MenuPanel } from './Menu'
10
+ import { PreviewCard } from './PreviewCard'
11
+
12
+ const HOVER = '(hover: hover)'
13
+
14
+ function subscribe(onChange: () => void) {
15
+ if (typeof window === 'undefined' || !window.matchMedia) return () => {}
16
+ const query = window.matchMedia(HOVER)
17
+ query.addEventListener('change', onChange)
18
+ return () => query.removeEventListener('change', onChange)
19
+ }
20
+
21
+ /** Whether this screen can hover. Without `matchMedia` (a test, a server) it is taken to. */
22
+ export function useCanHover(): boolean {
23
+ return useSyncExternalStore(
24
+ subscribe,
25
+ () => (typeof window === 'undefined' || !window.matchMedia ? true : window.matchMedia(HOVER).matches),
26
+ () => true,
27
+ )
28
+ }
29
+
30
+ export interface LinkPreviewProps {
31
+ linked: Linked
32
+ /** The trigger fills its column, as a row does; a chip is inline. */
33
+ block?: boolean
34
+ children: ReactNode
35
+ }
36
+
37
+ export function LinkPreview({ linked, block = false, children }: LinkPreviewProps) {
38
+ const canHover = useCanHover()
39
+ if (!canHover) return <>{children}</>
40
+ // A row fills its column; PreviewCard's own trigger is inline and would shrink it to its words.
41
+ return (
42
+ <PreviewCard side="bottom" content={<LinkCard link={{ state: 'ready', linked }} bare readOnly />} wrapperClassName={block ? 'grid w-full min-w-0 grid-cols-[minmax(0,1fr)]' : 'max-w-full'}>
43
+ {children}
44
+ </PreviewCard>
45
+ )
46
+ }
47
+
48
+ /** The preview drawn open, for a still picture of it: the same box PreviewCard draws. */
49
+ export function LinkPreviewOpen({ linked }: { linked: Linked }) {
50
+ return (
51
+ <MenuPanel className="w-[360px] p-0">
52
+ <LinkCard link={{ state: 'ready', linked }} bare readOnly />
53
+ </MenuPanel>
54
+ )
55
+ }
@@ -0,0 +1,47 @@
1
+ import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
2
+ import * as LinkRowStories from './LinkRow.stories'
3
+
4
+ <Meta of={LinkRowStories} />
5
+
6
+ # LinkRow
7
+
8
+ A link alone on its line: one 36px row for every kind, with what it has in its corner.
9
+
10
+ <Canvas of={LinkRowStories.EachKind} />
11
+
12
+ ## When
13
+
14
+ - **ready** — the icon, the key, the title as the link, and in the corner its status, ring, owner, members or authors, and when it changed. Hovering it shows its card; a tap opens it.
15
+ - **unread** — with something unread in its conversation the count shows, with its dot. A row otherwise keeps to one line and leaves the count to the card, except where conversations are what it holds.
16
+ - **loading** — the row in bars, at the row's height.
17
+ - **unavailable** — one line, "Item — not available.", with where it may still open (`openIn`). This is also what a card draws when it is not available: **LinkUnavailable**, from this file.
18
+
19
+ <Canvas of={LinkRowStories.NotAvailable} />
20
+
21
+ ## When not
22
+
23
+ - A link in a sentence → **LinkChip**.
24
+ - The one card under a piece of text → **CardUnder**.
25
+
26
+ ## How
27
+
28
+ ```tsx
29
+ import { LinkRow } from '@estiva-app/ui'
30
+
31
+ <LinkRow link={{ state: 'ready', linked }} />
32
+ ```
33
+
34
+ - The whole row opens `linked.href`; the conversation count opens `linked.conversation.href` on its own.
35
+ - `linked.conversation.unread` is the app's to say, and only for a member of it.
36
+
37
+ ## What it owns
38
+
39
+ | It does | So you never write |
40
+ |---|---|
41
+ | Covers the row with its link, with the count above it | a click handler on the row |
42
+ | Shows the card on hover, and none on a screen that cannot hover | a hover card of your own |
43
+ | Holds the row's height while it loads and when it is not available | a placeholder height of your own |
44
+
45
+ ## Props
46
+
47
+ <Controls of={LinkRowStories.Default} />