@estiva-app/ui 0.39.0 → 0.41.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@estiva-app/ui",
3
- "version": "0.39.0",
3
+ "version": "0.41.0",
4
4
  "description": "Estiva's design tokens (the contract) and a small set of primitives (a convenience) for every Estiva app.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/registry.json CHANGED
@@ -4,9 +4,9 @@
4
4
  "kind": "package",
5
5
  "repo": "estiva-ui",
6
6
  "package": "@estiva-app/ui",
7
- "packageVersion": "0.39.0",
8
- "exports": 88,
9
- "typeExports": 97,
7
+ "packageVersion": "0.41.0",
8
+ "exports": 90,
9
+ "typeExports": 101,
10
10
  "files": null
11
11
  },
12
12
  "storybook": {
@@ -792,6 +792,18 @@
792
792
  "required": false,
793
793
  "note": null
794
794
  },
795
+ {
796
+ "name": "href",
797
+ "takes": "text",
798
+ "required": false,
799
+ "note": "The chip is a link: a real anchor with the chip's own round shape, so the keyboard ring is round too."
800
+ },
801
+ {
802
+ "name": "onClick",
803
+ "takes": "MouseEventHandler<HTMLAnchorElement>",
804
+ "required": false,
805
+ "note": "With `href`: a router app takes the click here, like `Link`."
806
+ },
795
807
  {
796
808
  "name": "className",
797
809
  "takes": "text",
@@ -825,13 +837,13 @@
825
837
  "bg-warning-muted text-warning-default signal:border signal:border-warning-outline signal:shadow-glow-warning",
826
838
  "bg-success-muted text-success-default signal:border signal:border-success-outline",
827
839
  "bg-error-muted text-error-default signal:border signal:border-error-outline",
840
+ "overflow-x-clip text-ellipsis whitespace-nowrap signal:tracking-wide text-chip signal:font-mono signal:text-small signal:font-semibold signal:tabular-nums",
841
+ "overflow-x-clip text-ellipsis whitespace-nowrap signal:tracking-wide",
828
842
  "rounded-full bg-accent-muted text-accent-primary signal:border signal:border-accent-outline",
829
843
  "rounded-full bg-info-muted text-info-default signal:border signal:border-info-outline",
830
844
  "rounded-full bg-warning-muted text-warning-default signal:border signal:border-warning-outline signal:shadow-glow-warning",
831
845
  "rounded-full bg-success-muted text-success-default signal:border signal:border-success-outline",
832
- "rounded-full bg-error-muted text-error-default signal:border signal:border-error-outline",
833
- "overflow-x-clip text-ellipsis whitespace-nowrap signal:tracking-wide text-chip signal:font-mono signal:text-small signal:font-semibold signal:tabular-nums",
834
- "overflow-x-clip text-ellipsis whitespace-nowrap signal:tracking-wide"
846
+ "rounded-full bg-error-muted text-error-default signal:border signal:border-error-outline"
835
847
  ]
836
848
  },
837
849
  {
@@ -2749,6 +2761,12 @@
2749
2761
  "required": false,
2750
2762
  "note": "One line, cut with an ellipsis when it does not fit — a title that is a link."
2751
2763
  },
2764
+ {
2765
+ "name": "cover",
2766
+ "takes": "true/false",
2767
+ "required": false,
2768
+ "note": "The link covers the row it sits in: a click anywhere on the row opens it, and the keyboard ring goes round the whole row."
2769
+ },
2752
2770
  {
2753
2771
  "name": "children",
2754
2772
  "takes": "anything",
@@ -2785,8 +2803,13 @@
2785
2803
  "app": null,
2786
2804
  "looks": [
2787
2805
  "text-info-default underline underline-offset-2 hover:opacity-80",
2806
+ "text-info-default underline underline-offset-2 hover:opacity-80 after:rounded-md focus-visible:outline-none",
2788
2807
  "text-info-default underline underline-offset-2 hover:opacity-80 truncate",
2789
- "underline underline-offset-2 hover:text-text-primary truncate"
2808
+ "text-info-default underline underline-offset-2 hover:opacity-80 truncate after:rounded-md focus-visible:outline-none",
2809
+ "hover:underline truncate after:rounded-md focus-visible:outline-none",
2810
+ "underline underline-offset-2 hover:text-text-primary after:rounded-md focus-visible:outline-none",
2811
+ "underline underline-offset-2 hover:text-text-primary truncate",
2812
+ "underline underline-offset-2 hover:text-text-primary truncate after:rounded-md focus-visible:outline-none"
2790
2813
  ]
2791
2814
  },
2792
2815
  {
@@ -4079,6 +4102,93 @@
4079
4102
  "app": null,
4080
4103
  "looks": []
4081
4104
  },
4105
+ {
4106
+ "name": "RichText",
4107
+ "repo": "estiva-ui",
4108
+ "kind": "component",
4109
+ "importPath": "@estiva-app/ui",
4110
+ "sourceFile": "src/RichText.tsx",
4111
+ "purpose": "Formatted text, drawn from a parsed tree: headings, paragraphs, both lists, a quote, code, a divider, a table and links, in one look for every app.",
4112
+ "purposeFrom": "page",
4113
+ "props": [
4114
+ {
4115
+ "name": "blocks",
4116
+ "takes": "readonly RichTextBlock[]",
4117
+ "required": true,
4118
+ "note": "The tree, already parsed."
4119
+ },
4120
+ {
4121
+ "name": "size",
4122
+ "takes": "default | small",
4123
+ "required": false,
4124
+ "note": "Default `default`, the size of text you read."
4125
+ },
4126
+ {
4127
+ "name": "renderText",
4128
+ "takes": "a handler",
4129
+ "required": false,
4130
+ "note": "Draws plain text: what is left of a run once its links are drawn."
4131
+ },
4132
+ {
4133
+ "name": "renderReference",
4134
+ "takes": "a handler",
4135
+ "required": false,
4136
+ "note": "Draws a run that stands for an address. By default its text."
4137
+ },
4138
+ {
4139
+ "name": "renderBlock",
4140
+ "takes": "a handler",
4141
+ "required": false,
4142
+ "note": "Draws a block this part has no design for (an attachment, a widget)."
4143
+ }
4144
+ ],
4145
+ "variants": [
4146
+ {
4147
+ "prop": "size",
4148
+ "values": [
4149
+ "default",
4150
+ "small"
4151
+ ]
4152
+ }
4153
+ ],
4154
+ "ownsBehaviours": [],
4155
+ "status": "stable",
4156
+ "migrationStage": null,
4157
+ "docsId": "components-richtext--docs",
4158
+ "storyId": "components-richtext--every-block",
4159
+ "docPage": "src/RichText.mdx",
4160
+ "app": null,
4161
+ "looks": [
4162
+ "[&>:is(h1,h2,h3)]:mb-1 [&>:is(h1,h2,h3)]:mt-2 [&>:is(h1,h2,h3):first-child]:mt-0 [&>:is(h1,h2,h3):last-child]:mb-0 [&>:is(h1,h2,h3)+:is(h1,h2,h3)]:mt-1 [&>:is(h1,h2)]:text-text-primary",
4163
+ "[&_:is(ul,ol)]:pl-5 [&_li>p]:m-0 [&_li]:break-words [&_li]:marker:text-text-muted [&_ol]:list-decimal [&_ul]:list-disc",
4164
+ "[&_blockquote>p]:m-0 [&_blockquote]:border-l-[3px] [&_blockquote]:border-border-strong [&_blockquote]:pl-2.5",
4165
+ "[&_:not(pre)>code]:rounded-sm [&_:not(pre)>code]:bg-bg-code [&_:not(pre)>code]:px-1 [&_:not(pre)>code]:py-0.5 [&_code]:font-mono [&_code]:text-[0.9em] [&_code]:text-text-primary",
4166
+ "[&_pre>code]:text-[1em] [&_pre]:whitespace-pre-wrap [&_pre]:break-words [&_pre]:rounded-md [&_pre]:bg-bg-code [&_pre]:p-2 [&_pre]:font-mono [&_pre]:text-[0.9em] [&_pre]:text-text-primary",
4167
+ "[&_:is(th,td)>p]:m-0 [&_:is(th,td)]:border [&_:is(th,td)]:border-border-default [&_:is(th,td)]:px-2 [&_:is(th,td)]:py-1 [&_:is(th,td)]:text-left [&_:is(th,td)]:align-top [&_table]:w-full [&_table]:border-collapse [&_th]:bg-bg-code [&_th]:font-medium [&_th]:text-text-primary",
4168
+ "[&_em]:italic [&_strong]:font-semibold [&_u]:underline [&_u]:underline-offset-2",
4169
+ "text-body-2 [&>:is(h2,h3)]:text-body-2 [&>:is(h2,h3)]:font-semibold [&>h1]:text-h4",
4170
+ "text-caption leading-snug [&>:is(h2,h3)]:text-caption [&>:is(h2,h3)]:font-semibold [&>h1]:text-body-2 [&>h1]:font-semibold"
4171
+ ]
4172
+ },
4173
+ {
4174
+ "name": "richTextClassName",
4175
+ "repo": "estiva-ui",
4176
+ "kind": "helper",
4177
+ "importPath": "@estiva-app/ui",
4178
+ "sourceFile": "src/RichText.tsx",
4179
+ "purpose": "RichText's look, for a box you draw yourself: an editor's, so that what is being written looks the same as what is read.",
4180
+ "purposeFrom": "comment",
4181
+ "props": [],
4182
+ "variants": [],
4183
+ "ownsBehaviours": [],
4184
+ "status": "stable",
4185
+ "migrationStage": null,
4186
+ "docsId": "components-richtext--docs",
4187
+ "storyId": "components-richtext--every-block",
4188
+ "docPage": null,
4189
+ "app": null,
4190
+ "looks": []
4191
+ },
4082
4192
  {
4083
4193
  "name": "ScrollArea",
4084
4194
  "repo": "estiva-ui",
package/src/Chip.mdx CHANGED
@@ -20,8 +20,8 @@ muted tints; the label is the `chip` type token, 11px / 500.
20
20
 
21
21
  ## When not
22
22
 
23
- - Something clickable → **Button** (small) or **MenuItem** — a Chip is not
24
- a control.
23
+ - Something that does an action → **Button** (small) or **MenuItem**. A Chip
24
+ is not a control. It can only be a link, with `href`.
25
25
  - A removable token in a multi-select → **InputChip** (see ChipInput).
26
26
  - A count beside a tab label → **Tabs** draws its own mono number, not a
27
27
  Chip.
@@ -37,6 +37,17 @@ import { Chip } from '@estiva-app/ui'
37
37
  - `leadingIcon` / `trailingIcon` take a 12px icon; the slots centre it, so
38
38
  it never rides the text baseline.
39
39
  - A count alone works too — `label="3"`, no icon.
40
+ - `href` makes the chip a link to the thing it names: a real anchor with the
41
+ chip's round shape, so the keyboard ring is round too. A router app passes
42
+ `onClick`, as with **Link**. Inside a row whose **Link** has `cover`, it
43
+ stays clickable on its own: the row opens one thing, the chip another.
44
+
45
+ ```tsx
46
+ <div className="relative flex items-center gap-3">
47
+ <Link href={itemUrl} variant="plain" cover>Item one</Link>
48
+ <Chip href={groupUrl} label="Group" />
49
+ </div>
50
+ ```
40
51
 
41
52
  ## What it owns
42
53
 
@@ -27,6 +27,8 @@ export const WithLeadingIcon: Story = { args: { type: 'info', label: 'Replies',
27
27
  export const WithTrailingIcon: Story = { args: { type: 'success', label: 'Sorted', trailingIcon: <IconArrowUp className="size-3" stroke={1.5} /> } }
28
28
  /** A count alone. */
29
29
  export const CountOnly: Story = { args: { type: 'neutral', label: '2' } }
30
+ /** A link to the thing it names. Tab to it: the ring is round, like the chip. */
31
+ export const AsLink: Story = { args: { type: 'neutral', label: 'Group', href: '#' } }
30
32
 
31
33
  /** All six colour types. */
32
34
  export const AllTypes: Story = {
@@ -0,0 +1,36 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * What the Chip page claims about `href`, pinned: without it the chip is not a
4
+ * link; with it the chip is a real anchor named by its label, keeps its round
5
+ * shape, sits above a covering link (`relative`), and hands the click to a
6
+ * router app through `onClick`.
7
+ */
8
+ import { afterEach, describe, expect, it, vi } from 'vitest'
9
+ import { cleanup, render, screen } from '@testing-library/react'
10
+ import userEvent from '@testing-library/user-event'
11
+ import { Chip } from './Chip'
12
+
13
+ afterEach(cleanup)
14
+
15
+ describe('Chip', () => {
16
+ it('is not a link without href', () => {
17
+ render(<Chip label="Group" />)
18
+ expect(screen.queryByRole('link')).toBeNull()
19
+ expect(screen.getByText('Group').parentElement?.tagName).toBe('DIV')
20
+ })
21
+
22
+ it('with href is an anchor named by its label, round, and lifted above a covering link', () => {
23
+ render(<Chip label="Group" href="/groups/one" />)
24
+ const link = screen.getByRole('link', { name: 'Group' })
25
+ expect(link.getAttribute('href')).toBe('/groups/one')
26
+ expect(link.classList.contains('rounded-full')).toBe(true)
27
+ expect(link.classList.contains('relative')).toBe(true)
28
+ })
29
+
30
+ it('a router app takes the click through onClick', async () => {
31
+ const onClick = vi.fn((event: { preventDefault: () => void }) => event.preventDefault())
32
+ render(<Chip label="Group" href="/groups/one" onClick={onClick} />)
33
+ await userEvent.click(screen.getByRole('link', { name: 'Group' }))
34
+ expect(onClick).toHaveBeenCalledTimes(1)
35
+ })
36
+ })
package/src/Chip.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import type { ReactNode } from 'react'
1
+ import type { MouseEventHandler, ReactNode } from 'react'
2
2
  import { cn } from './cn'
3
3
  import { CHIP_TEXT_CLASSES } from './looks'
4
4
 
@@ -20,6 +20,14 @@ export interface ChipProps {
20
20
  label?: string
21
21
  leadingIcon?: ReactNode
22
22
  trailingIcon?: ReactNode
23
+ /**
24
+ * The chip is a link: a real anchor with the chip's own round shape, so the
25
+ * keyboard ring is round too. It sits above a row's covering `Link`, so the
26
+ * row opens one thing and the chip another (UIG-33, Katerina 28 September).
27
+ */
28
+ href?: string
29
+ /** With `href`: a router app takes the click here, like `Link`. */
30
+ onClick?: MouseEventHandler<HTMLAnchorElement>
23
31
  className?: string
24
32
  }
25
33
 
@@ -32,9 +40,10 @@ const typeStyles: Record<ChipType, string> = {
32
40
  error: 'bg-error-muted text-error-default signal:border signal:border-error-outline',
33
41
  }
34
42
 
35
- export function Chip({ type = 'neutral', label, leadingIcon, trailingIcon, className }: ChipProps) {
36
- return (
37
- <div className={cn('inline-flex min-w-0 items-center justify-center gap-1.5 rounded-full max-h-[20px] px-2 py-1', typeStyles[type], className)}>
43
+ export function Chip({ type = 'neutral', label, leadingIcon, trailingIcon, href, onClick, className }: ChipProps) {
44
+ const classes = cn('inline-flex min-w-0 items-center justify-center gap-1.5 rounded-full max-h-[20px] px-2 py-1', typeStyles[type], className)
45
+ const inside = (
46
+ <>
38
47
  {leadingIcon && <span className="flex size-3 shrink-0 items-center justify-center">{leadingIcon}</span>}
39
48
  {label && (
40
49
  // A chip given a `max-w-*` cuts a long label with an ellipsis instead
@@ -48,6 +57,9 @@ export function Chip({ type = 'neutral', label, leadingIcon, trailingIcon, class
48
57
  <span className={cn('min-w-0 overflow-x-clip text-ellipsis whitespace-nowrap signal:tracking-wide', CHIP_TEXT_CLASSES)}>{label}</span>
49
58
  )}
50
59
  {trailingIcon && <span className="flex size-3 shrink-0 items-center justify-center">{trailingIcon}</span>}
51
- </div>
60
+ </>
52
61
  )
62
+ // `relative` lifts a linked chip above a covering link that comes before it.
63
+ if (href) return <a href={href} onClick={onClick} className={cn('relative', classes)}>{inside}</a>
64
+ return <div className={classes}>{inside}</div>
53
65
  }
package/src/Link.mdx CHANGED
@@ -24,6 +24,11 @@ the app decides how it navigates.
24
24
  what it wraps draws itself.
25
25
  - `external` — another site or app: it opens in a new tab, with
26
26
  `noopener noreferrer`.
27
+ - **`cover`** — a row that opens one thing wherever you click it: an item in a
28
+ list, a row of a table. The link covers the row, and the keyboard ring goes
29
+ round the whole row.
30
+
31
+ <Canvas of={LinkStories.Cover} />
27
32
 
28
33
  ## When not
29
34
 
@@ -33,6 +38,13 @@ the app decides how it navigates.
33
38
  - A link that looks like an icon button → **IconButton** with `href`.
34
39
  - A word standing for a person or a thing, drawn in a small box → **InlineChip**
35
40
  with `href`.
41
+ - A box that is one link → **Card** with `href`. `cover` is for a row that has
42
+ no frame of its own.
43
+ - `cover` on a row whose parts each open something different, or a row with
44
+ nothing to open. A row with only a second link inside it (a chip, say) is
45
+ fine: see How.
46
+ - Stretching a link over its row by hand (`after:absolute`): the lint refuses
47
+ it. Use `cover`.
36
48
 
37
49
  ## How
38
50
 
@@ -59,6 +71,26 @@ import { Link } from '@estiva-app/ui'
59
71
 
60
72
  - `truncate` keeps a title that is a link on one line, cut with an ellipsis.
61
73
  Its size and colour still come from the text around it.
74
+
75
+ - `cover` covers the nearest box that is `relative`: give the row `relative`.
76
+ The row keeps its own size, fill and hover; the link only adds the layer
77
+ and the ring. Pointing anywhere on the row counts as pointing at the link,
78
+ so `quiet` underlines the title while the row is hovered.
79
+ - A second link inside the row comes **after** the covering link and is
80
+ `relative` itself, so it sits above the layer. A **Chip** with `href` already
81
+ is.
82
+
83
+ ```tsx
84
+ <div className="relative flex h-9 items-center gap-3 px-3 hover:bg-bg-hover">
85
+ <Link href={itemUrl} variant="plain" cover onClick={navigate}>Item one</Link>
86
+ <Chip href={groupUrl} label="Group" onClick={navigate} />
87
+ </div>
88
+
89
+ <tr className="relative">
90
+ <td><Link href={itemUrl} variant="quiet" cover>Item one</Link></td>
91
+ <td>…</td>
92
+ </tr>
93
+ ```
62
94
  - `quiet`, `underlined` and `plain` take their size and colour from where they
63
95
  sit, so put those on your own element around the link, not on the link —
64
96
  the lint refuses a look passed in `className`.
@@ -1,6 +1,7 @@
1
1
  import type { Meta, StoryObj } from '@storybook/react-vite'
2
2
  import { Link } from './Link'
3
3
  import { Card } from './Card'
4
+ import { Chip } from './Chip'
4
5
 
5
6
  const meta = {
6
7
  title: 'Navigation/Link',
@@ -70,6 +71,54 @@ export const External: Story = {
70
71
  ),
71
72
  }
72
73
 
74
+ const ROWS = [
75
+ { key: 'A-1', title: 'Item one', group: 'Group', time: '2h' },
76
+ { key: 'A-2', title: 'Item two', group: 'Group', time: '1d' },
77
+ { key: 'A-3', title: 'Item three', time: '3d' },
78
+ ]
79
+
80
+ /**
81
+ * A row that is one link: click anywhere on it, or Tab to it and the ring goes
82
+ * round the whole row. The chip is a second link, and opens something else.
83
+ */
84
+ export const Cover: Story = {
85
+ parameters: { controls: { disable: true } },
86
+ render: () => (
87
+ <div className="flex w-full max-w-xl flex-col">
88
+ {ROWS.map((row) => (
89
+ <div key={row.key} className="relative flex h-9 items-center gap-3 rounded-md px-3 transition-colors hover:bg-bg-hover">
90
+ <Link href="#" variant="plain" cover className="flex min-w-0 flex-1 items-center gap-3">
91
+ <span className="w-10 shrink-0 font-mono text-caption text-text-muted">{row.key}</span>
92
+ <span className="min-w-0 flex-1 truncate text-body-2 text-text-primary">{row.title}</span>
93
+ </Link>
94
+ {row.group && <Chip href="#" label={row.group} />}
95
+ <span className="w-8 shrink-0 text-right text-caption text-text-muted">{row.time}</span>
96
+ </div>
97
+ ))}
98
+ </div>
99
+ ),
100
+ }
101
+
102
+ /** The same in a table: the row is `relative`, the title a `quiet` link that covers it. */
103
+ export const CoverInTable: Story = {
104
+ parameters: { controls: { disable: true } },
105
+ render: () => (
106
+ <table className="w-full max-w-xl text-body-2">
107
+ <tbody>
108
+ {ROWS.map((row) => (
109
+ <tr key={row.key} className="relative border-b border-border-subtle last:border-b-0 hover:bg-bg-hover">
110
+ <td className="w-14 px-3 py-2 font-mono text-caption text-text-muted">{row.key}</td>
111
+ <td className="px-3 py-2 text-text-primary">
112
+ <Link href="#" variant="quiet" cover>{row.title}</Link>
113
+ </td>
114
+ <td className="w-16 px-3 py-2 text-right text-caption text-text-muted">{row.time}</td>
115
+ </tr>
116
+ ))}
117
+ </tbody>
118
+ </table>
119
+ ),
120
+ }
121
+
73
122
  /** The four looks, each where it belongs. */
74
123
  export const AllVariants: Story = {
75
124
  parameters: { controls: { disable: true } },
package/src/Link.test.tsx CHANGED
@@ -88,6 +88,21 @@ describe('Link', () => {
88
88
  expect(classesOf(screen.getByRole('link'))).toEqual([])
89
89
  })
90
90
 
91
+ it('cover lays a layer over the row and moves the ring onto it; without it, nothing of the kind', () => {
92
+ render(
93
+ <>
94
+ <Link href="#" variant="plain" cover>
95
+ Row
96
+ </Link>
97
+ <Link href="#" variant="plain">
98
+ Words
99
+ </Link>
100
+ </>,
101
+ )
102
+ expect(classesOf(screen.getByRole('link', { name: 'Row' }))).toEqual(['after:absolute', 'after:inset-0', 'after:rounded-md', 'focus-visible:outline-none', 'focus-visible:after:[outline-style:auto]'])
103
+ expect(classesOf(screen.getByRole('link', { name: 'Words' }))).toEqual([])
104
+ })
105
+
91
106
  it('a ref reaches the anchor', () => {
92
107
  const ref = createRef<HTMLAnchorElement>()
93
108
  render(
package/src/Link.tsx CHANGED
@@ -18,6 +18,15 @@ import { cn } from './cn'
18
18
  * and a solid version became this one, solid (her ruling, 13 September).
19
19
  * - `plain` — no look at all: a card or a row that is a link draws itself.
20
20
  *
21
+ * **`cover`: a row that is one link** (UIG-33, Katerina 28 September). The link
22
+ * stretches an invisible layer over the nearest box the app has given
23
+ * `relative` (a row, a table row), so a click anywhere on it opens the link, and
24
+ * the keyboard ring goes round that whole box rather than round the words. The
25
+ * row stays the app's own: its size, its fill, its hover. A second link inside
26
+ * the row stays clickable when it comes after the covering link and is
27
+ * `relative` itself, which a `Chip` with an `href` already is. Both apps
28
+ * stretched a link by hand before this, each under a written exception.
29
+ *
21
30
  * **Navigation is the app's.** The package cannot know a router, so like
22
31
  * `NavItem` it renders a real anchor and passes every anchor prop through: a
23
32
  * router app hands in `onClick`, prevents the default and navigates, and the
@@ -25,6 +34,13 @@ import { cn } from './cn'
25
34
  */
26
35
  export type LinkVariant = 'text' | 'quiet' | 'underlined' | 'plain'
27
36
 
37
+ /**
38
+ * The covering layer, and the ring moved onto it: `outline-none` takes the ring
39
+ * off the words, and `outline-style: auto` draws the browser's own ring round the
40
+ * layer, the same ring every other control shows, only the size of the row.
41
+ */
42
+ const COVER_CLASSES = 'after:absolute after:inset-0 after:rounded-md focus-visible:outline-none focus-visible:after:[outline-style:auto]'
43
+
28
44
  const VARIANT_CLASSES: Record<LinkVariant, string> = {
29
45
  text: 'text-info-default underline underline-offset-2 hover:opacity-80',
30
46
  quiet: 'hover:underline',
@@ -46,15 +62,20 @@ export interface LinkProps extends ComponentPropsWithRef<'a'> {
46
62
  * Its size and colour still come from the text around it (UIG-9, 17 September).
47
63
  */
48
64
  truncate?: boolean
65
+ /**
66
+ * The link covers the row it sits in: a click anywhere on the row opens it,
67
+ * and the keyboard ring goes round the whole row. Give the row `relative`.
68
+ */
69
+ cover?: boolean
49
70
  children: ReactNode
50
71
  }
51
72
 
52
- export function Link({ href, variant = 'text', external = false, truncate = false, className, children, ...props }: LinkProps) {
73
+ export function Link({ href, variant = 'text', external = false, truncate = false, cover = false, className, children, ...props }: LinkProps) {
53
74
  return (
54
75
  <a
55
76
  href={href}
56
77
  {...(external ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
57
- className={cn(VARIANT_CLASSES[variant], truncate && 'truncate', className)}
78
+ className={cn(VARIANT_CLASSES[variant], truncate && 'truncate', cover && COVER_CLASSES, className)}
58
79
  {...props}
59
80
  >
60
81
  {children}
@@ -0,0 +1,79 @@
1
+ import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
2
+ import * as RichTextStories from './RichText.stories'
3
+
4
+ <Meta of={RichTextStories} />
5
+
6
+ # RichText
7
+
8
+ Formatted text, drawn from a parsed tree: headings, paragraphs, both lists, a
9
+ quote, code, a divider, a table and links, in one look for every app.
10
+
11
+ <Canvas of={RichTextStories.EveryBlock} />
12
+
13
+ ## When
14
+
15
+ - **`default`** — text someone reads: a post, a reply, a description.
16
+ - **`small`** — the same text one step down, with every heading, list, quote
17
+ and table kept: an item shown inside a card.
18
+ - `renderReference` — a run that stands for an address: the app draws what it
19
+ points at, a person or an item.
20
+ - `renderText` — words the app recognises in plain text, drawn as chips.
21
+ - `renderBlock` — a block this part has no design for, such as an attachment.
22
+
23
+ ## When not
24
+
25
+ - One word standing for a person or a thing, inside your own sentence →
26
+ **InlineChip**.
27
+ - One link inside your own sentence → **Link**.
28
+ - A value set out as a label and its value → **Property**.
29
+
30
+ ## How
31
+
32
+ ```tsx
33
+ import { InlineChip, RichText } from '@estiva-app/ui'
34
+
35
+ // toRenderTree comes from @estiva-app/protocol
36
+ <RichText
37
+ blocks={toRenderTree(value, format)}
38
+ renderReference={(reference) => <InlineChip tone="person">{nameOf(reference)}</InlineChip>}
39
+ />
40
+ ```
41
+
42
+ - **It takes the tree, not the text.** Parse first; the format comes from the
43
+ item's tag, never from its body. The protocol's `RenderBlock` fits
44
+ `RichTextBlock`. To draw a block somewhere else (below the text, as a card),
45
+ leave it out of `blocks`.
46
+ - **The colour is set here**: the text is `text-secondary`, and headings
47
+ of level 1 and 2 are `text-primary`. Do not wrap it to recolour it.
48
+ - **Links are found here**, in every run that is not code: `[words](address)`
49
+ and a bare `https://` address. Both open in a new tab, and a click on one
50
+ is not also a click on what the text sits in, such as a card. `renderText`
51
+ gets only the text between links, so an address is never read as a name.
52
+ - **Nothing is set as HTML.** Every string reaches the page as text; a `<b>`
53
+ typed by an author shows as `<b>`.
54
+ - A block of a kind it does not know shows its words and its children. It is
55
+ never dropped.
56
+ - Code, in a sentence or as a block, is 0.9 of the text around it, so it
57
+ shrinks with `small`. A long line in a block wraps; nothing scrolls
58
+ sideways.
59
+ - Each block carries its `id` as `data-block-id`, for an app that points at
60
+ one block.
61
+ - **An editor wears the same look.** Put `richTextClassName()` (or
62
+ `richTextClassName('small')`) on the editor's root, so what is being written
63
+ looks exactly like what is read. It styles the elements inside the box,
64
+ including the paragraph an editor puts inside a list item, a quote and a
65
+ table cell.
66
+
67
+ ```tsx
68
+ import { richTextClassName } from '@estiva-app/ui'
69
+
70
+ const editorAttributes = { class: richTextClassName('default', 'outline-none') }
71
+ ```
72
+
73
+ ## What it owns
74
+
75
+ Nothing. It only draws. Clicks and keys are yours.
76
+
77
+ ## Props
78
+
79
+ <Controls of={RichTextStories.EveryBlock} />
@@ -0,0 +1,91 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { IconSquareRounded } from '@tabler/icons-react'
3
+ import { RichText, type RichTextBlock, type RichTextRun } from './RichText'
4
+ import { InlineChip } from './InlineChip'
5
+ import { Card } from './Card'
6
+
7
+ const t = (text: string, ...marks: string[]): RichTextRun => ({ text, marks })
8
+ const p = (...inline: RichTextRun[]): RichTextBlock => ({ type: 'paragraph', inline })
9
+ const items = (...texts: string[]): RichTextBlock[] => texts.map((text) => ({ type: 'listItem', inline: [t(text)] }))
10
+ const cell = (type: 'tableHeader' | 'tableCell', text: string): RichTextBlock => ({ type: 'unknown', typeName: type, children: [p(t(text))] })
11
+ const row = (type: 'tableHeader' | 'tableCell', ...texts: string[]): RichTextBlock => ({ type: 'unknown', typeName: 'tableRow', children: texts.map((x) => cell(type, x)) })
12
+
13
+ /** Every block the part draws, in the order a reader meets them. */
14
+ const EVERY_BLOCK: RichTextBlock[] = [
15
+ { type: 'heading', level: 1, inline: [t('A heading')] },
16
+ { type: 'heading', level: 2, inline: [t('A smaller heading')] },
17
+ { type: 'heading', level: 3, inline: [t('A third level, in the text colour')] },
18
+ p(t('Text can be '), t('bold', 'bold'), t(', '), t('italic', 'italic'), t(' or '), t('underlined', 'underline'), t(', and hold '), t('code', 'code'), t(' in a sentence.\nA line break stays a line break.')),
19
+ { type: 'bulletList', children: items('An item', 'Another item') },
20
+ { type: 'orderedList', children: items('The first step', 'The second step') },
21
+ { type: 'blockquote', inline: [t('A quote, set in from a line.')] },
22
+ { type: 'codeBlock', language: 'ts', inline: [t('const item = 1 // a long line of code folds onto the next line instead of scrolling sideways')] },
23
+ { type: 'horizontalRule' },
24
+ {
25
+ type: 'table',
26
+ children: [row('tableHeader', 'Label', 'Value'), row('tableCell', 'Item one', 'One'), row('tableCell', 'Item two', 'Two')],
27
+ },
28
+ ]
29
+
30
+ const meta = {
31
+ title: 'Components/RichText',
32
+ component: RichText,
33
+ args: { blocks: EVERY_BLOCK, size: 'default' },
34
+ argTypes: { size: { control: 'inline-radio', options: ['default', 'small'] } },
35
+ decorators: [(Story) => <div className="w-[480px]"><Story /></div>],
36
+ } satisfies Meta<typeof RichText>
37
+
38
+ export default meta
39
+ type Story = StoryObj<typeof meta>
40
+
41
+ /** Every block: headings, text and its marks, both lists, a quote, code, a divider and a table. */
42
+ export const EveryBlock: Story = {}
43
+
44
+ /** One step down, with every heading, list, quote and table kept: an item shown inside a card. */
45
+ export const Small: Story = {
46
+ args: { size: 'small' },
47
+ render: (args) => (
48
+ <Card className="p-3">
49
+ <RichText {...args} />
50
+ </Card>
51
+ ),
52
+ }
53
+
54
+ /** A link written with its own words, and a bare address, which may break anywhere. Code is never read for links. */
55
+ export const Links: Story = {
56
+ args: {
57
+ blocks: [
58
+ p(t('A link with its own words: [the guide](https://example.com/guide). A bare address: https://example.com/a/long/address/that/breaks/where/it/must.')),
59
+ p(t('Inside code it stays text: '), t('[not a link](https://example.com)', 'code')),
60
+ ],
61
+ },
62
+ }
63
+
64
+ /** What an app draws itself: a run that stands for an address, and words it recognises in plain text. */
65
+ export const WithTheAppsOwnParts: Story = {
66
+ args: {
67
+ blocks: [
68
+ p(
69
+ t('Assigned to '),
70
+ { text: 'nostr:npub1example', marks: [], reference: 'nostr:npub1example' },
71
+ t(', see [[Item one]] for the rest.'),
72
+ ),
73
+ ],
74
+ renderReference: () => <InlineChip tone="person">Name</InlineChip>,
75
+ renderText: (text) =>
76
+ text.split(/(\[\[[^\]]+\]\])/).map((part, i) =>
77
+ part.startsWith('[[') ? (
78
+ <InlineChip key={i} icon={<IconSquareRounded size={16} stroke={1.5} />}>{part.slice(2, -2)}</InlineChip>
79
+ ) : (
80
+ part
81
+ ),
82
+ ),
83
+ },
84
+ }
85
+
86
+ /** A block this part has no design for, from a later version: its words are drawn, never dropped. */
87
+ export const UnknownBlock: Story = {
88
+ args: {
89
+ blocks: [p(t('The block below is of a kind this part does not know.')), { type: 'unknown', typeName: 'later', inline: [t('Its words are still here.')] }],
90
+ },
91
+ }