@estiva-app/ui 0.12.9 → 0.13.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 (55) hide show
  1. package/dist/AttachmentCard.d.ts +52 -0
  2. package/dist/AttachmentCard.d.ts.map +1 -0
  3. package/dist/Card.d.ts +48 -0
  4. package/dist/Card.d.ts.map +1 -0
  5. package/dist/InlineChip.d.ts +59 -0
  6. package/dist/InlineChip.d.ts.map +1 -0
  7. package/dist/Link.d.ts +37 -0
  8. package/dist/Link.d.ts.map +1 -0
  9. package/dist/Menu.d.ts +12 -2
  10. package/dist/Menu.d.ts.map +1 -1
  11. package/dist/Popover.d.ts +17 -2
  12. package/dist/Popover.d.ts.map +1 -1
  13. package/dist/ProgressBar.d.ts +32 -0
  14. package/dist/ProgressBar.d.ts.map +1 -0
  15. package/dist/index.d.ts +5 -0
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +697 -358
  18. package/dist/index.js.map +4 -4
  19. package/package.json +1 -1
  20. package/src/AttachmentCard.mdx +62 -0
  21. package/src/AttachmentCard.stories.tsx +92 -0
  22. package/src/AttachmentCard.test.tsx +172 -0
  23. package/src/AttachmentCard.tsx +324 -0
  24. package/src/Card.mdx +68 -0
  25. package/src/Card.stories.tsx +109 -0
  26. package/src/Card.test.tsx +106 -0
  27. package/src/Card.tsx +115 -0
  28. package/src/EmptyState.mdx +14 -0
  29. package/src/EmptyState.stories.tsx +28 -7
  30. package/src/EmptyState.test.tsx +8 -0
  31. package/src/InlineChip.mdx +56 -0
  32. package/src/InlineChip.stories.tsx +59 -0
  33. package/src/InlineChip.test.tsx +81 -0
  34. package/src/InlineChip.tsx +90 -0
  35. package/src/Link.mdx +59 -0
  36. package/src/Link.stories.tsx +102 -0
  37. package/src/Link.test.tsx +100 -0
  38. package/src/Link.tsx +58 -0
  39. package/src/Menu.test.tsx +20 -0
  40. package/src/Menu.tsx +17 -5
  41. package/src/Person.stories.tsx +1 -1
  42. package/src/PersonTrigger.stories.tsx +1 -1
  43. package/src/Popover.mdx +4 -2
  44. package/src/Popover.stories.tsx +11 -9
  45. package/src/Popover.test.tsx +38 -0
  46. package/src/Popover.tsx +20 -4
  47. package/src/ProgressBar.mdx +47 -0
  48. package/src/ProgressBar.stories.tsx +48 -0
  49. package/src/ProgressBar.test.tsx +62 -0
  50. package/src/ProgressBar.tsx +56 -0
  51. package/src/ReactionPicker.mdx +1 -1
  52. package/src/ReactionPicker.stories.tsx +3 -2
  53. package/src/Toolbar.stories.tsx +2 -1
  54. package/src/index.ts +12 -0
  55. package/stories/Choosing.mdx +5 -0
@@ -0,0 +1,81 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * What the InlineChip page claims, pinned: without `href` it is a label, with
4
+ * one it is a link a router can take; every tone keeps the shape and its own
5
+ * size and colour through `cn()`; the icon sits in its 16px box; and the class
6
+ * function a string-only renderer uses gives the same classes as the component.
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 { INLINE_CHIP_CLASSES, INLINE_CHIP_TONE_CLASSES, InlineChip, inlineChipClassName } from './InlineChip'
12
+
13
+ afterEach(cleanup)
14
+
15
+ const classesOf = (el: Element) => el.getAttribute('class')?.split(' ').filter(Boolean) ?? []
16
+
17
+ describe('InlineChip', () => {
18
+ it('without href it is a label, not a link', () => {
19
+ render(<InlineChip>Label</InlineChip>)
20
+ const chip = screen.getByText('Label')
21
+ expect(chip.tagName).toBe('SPAN')
22
+ expect(screen.queryByRole('link')).toBeNull()
23
+ })
24
+
25
+ it('with href it is a link, and a router app takes the click with the href intact', async () => {
26
+ const onClick = vi.fn((event: { preventDefault: () => void }) => event.preventDefault())
27
+ render(
28
+ <InlineChip href="/somewhere" onClick={onClick}>
29
+ Label
30
+ </InlineChip>,
31
+ )
32
+ const link = screen.getByRole('link', { name: 'Label' })
33
+ await userEvent.click(link)
34
+ expect(onClick).toHaveBeenCalledTimes(1)
35
+ expect(link.getAttribute('href')).toBe('/somewhere')
36
+ })
37
+
38
+ it('is one line high and pinned to the top of the line, in every tone', () => {
39
+ for (const tone of Object.keys(INLINE_CHIP_TONE_CLASSES) as (keyof typeof INLINE_CHIP_TONE_CLASSES)[]) {
40
+ const classes = inlineChipClassName(tone).split(' ')
41
+ expect(classes).toContain('align-top')
42
+ expect(classes).toContain('mx-0.5')
43
+ // One height only: 1.4em of the body size, or the same 19.6px written out where the text is smaller.
44
+ const heights = classes.filter((c) => c.startsWith('h-'))
45
+ expect(heights).toEqual([tone === 'quiet' ? 'h-[19.6px]' : 'h-[1.4em]'])
46
+ }
47
+ })
48
+
49
+ it('each tone keeps its size and its colour through cn()', () => {
50
+ const neutral = inlineChipClassName('neutral').split(' ')
51
+ expect(neutral).toContain('text-body-2')
52
+ expect(neutral).toContain('text-text-primary')
53
+ const person = inlineChipClassName('person').split(' ')
54
+ expect(person).toContain('text-body-2')
55
+ expect(person).toContain('text-accent-primary')
56
+ const urgent = inlineChipClassName('urgent').split(' ')
57
+ expect(urgent).toContain('text-warning-default')
58
+ // quiet is smaller: its caption size replaces the body size, and its colour survives beside it
59
+ const quiet = inlineChipClassName('quiet').split(' ')
60
+ expect(quiet).toContain('text-caption')
61
+ expect(quiet).not.toContain('text-body-2')
62
+ expect(quiet).toContain('text-text-muted')
63
+ })
64
+
65
+ it('the component and the class function give the same classes', () => {
66
+ render(
67
+ <InlineChip tone="person" className="max-w-[24ch]">
68
+ Label
69
+ </InlineChip>,
70
+ )
71
+ expect(classesOf(screen.getByText('Label'))).toEqual(inlineChipClassName('person', 'max-w-[24ch]').split(' '))
72
+ expect(INLINE_CHIP_CLASSES.split(' ').every((c) => classesOf(screen.getByText('Label')).includes(c))).toBe(true)
73
+ })
74
+
75
+ it('draws the icon first, in its 16px box', () => {
76
+ render(<InlineChip icon={<svg data-testid="icon" />}>Label</InlineChip>)
77
+ const box = screen.getByTestId('icon').parentElement!
78
+ expect(classesOf(box)).toEqual(expect.arrayContaining(['size-4', 'shrink-0']))
79
+ expect(box.parentElement!.firstElementChild).toBe(box)
80
+ })
81
+ })
@@ -0,0 +1,90 @@
1
+ import type { ComponentPropsWithRef, ReactNode } from 'react'
2
+ import { cn } from './cn'
3
+
4
+ /**
5
+ * A word in a sentence that stands for something: a person, a place, a thing
6
+ * written into running text. One line high, level with the words around it.
7
+ *
8
+ * The shape is Peek's inline chip (D67, Peek PR #206, 2026-09-13), class for
9
+ * class, moved into the package so both apps draw one (UIG-27). Three things
10
+ * changed on the way, all ruled by Katerina:
11
+ *
12
+ * - the person tone is `person`, not `mention` — every chip here is a
13
+ * mention of something;
14
+ * - the alignment is a class (`align-top h-[1.4em]`), not a `style` object,
15
+ * so it is part of the class list like every other size in the package;
16
+ * - a chip that leads somewhere takes `href` and `onClick`, the way `NavItem`
17
+ * does: the package draws an anchor, the app's router takes the click.
18
+ *
19
+ * **Why `align-top` and `1.4em`.** `1.4em` is the body text's line height,
20
+ * so the chip is exactly one line tall; `vertical-align: top` pins it to the
21
+ * line box, which keeps the line at 19.6px and the chip's words level with
22
+ * the sentence's, with or without an icon. Measured in Peek on 2026-09-13:
23
+ * `text-bottom` made the line 21.2px and lifted the chip's words 1.6px;
24
+ * `baseline` lifted them 3.8px once an icon came first.
25
+ */
26
+
27
+ /** The shape every tone shares. Exported for a rich-text editor that renders chips from strings. */
28
+ export const INLINE_CHIP_CLASSES =
29
+ 'inline-flex h-[1.4em] items-center gap-1 rounded-sm px-1 mx-0.5 align-top text-body-2 font-normal select-none'
30
+
31
+ /** What a chip is made of, by what it stands for. */
32
+ export const INLINE_CHIP_TONE_CLASSES = {
33
+ /** A thing or a place — anything that is not a person. */
34
+ neutral: 'bg-bg-active text-text-primary',
35
+ /** A person. */
36
+ person: 'bg-accent-muted text-accent-primary',
37
+ /** A person, called urgently. */
38
+ urgent: 'bg-warning-muted text-warning-default',
39
+ /**
40
+ * A reference nobody could resolve — still a mention, just an anonymous one.
41
+ * Its height is the body line's 19.6px written out, because `1.4em` here is
42
+ * the caption's em: 16.8px, with the letters 1.8px above the sentence's
43
+ * baseline. At 19.6px they sit 0.4px off it (measured 2026-09-14; Peek's
44
+ * copy of this chip still has the 16.8px box).
45
+ */
46
+ quiet: 'h-[19.6px] bg-bg-active text-text-muted font-mono text-caption',
47
+ } as const
48
+
49
+ export type InlineChipTone = keyof typeof INLINE_CHIP_TONE_CLASSES
50
+
51
+ /** The chip's classes for a tone, with anything the caller adds. For renderers that cannot use the component. */
52
+ export function inlineChipClassName(tone: InlineChipTone, className?: string) {
53
+ return cn(INLINE_CHIP_CLASSES, INLINE_CHIP_TONE_CLASSES[tone], className)
54
+ }
55
+
56
+ export interface InlineChipProps extends Omit<ComponentPropsWithRef<'a'>, 'children'> {
57
+ /** What the chip stands for. Default `neutral`. */
58
+ tone?: InlineChipTone
59
+ /** Drawn before the label in a 16px box: a 16px icon, or a 14px one it centres. */
60
+ icon?: ReactNode
61
+ /**
62
+ * Where the chip leads. With it the chip is an anchor; without it, a span.
63
+ * A router app passes `onClick` too, and navigates there itself.
64
+ */
65
+ href?: string
66
+ children: ReactNode
67
+ }
68
+
69
+ export function InlineChip({ tone = 'neutral', icon, href, className, children, ...props }: InlineChipProps) {
70
+ const body = (
71
+ <>
72
+ {icon && <span className="flex size-4 shrink-0 items-center justify-center text-text-secondary">{icon}</span>}
73
+ {children}
74
+ </>
75
+ )
76
+ const classes = inlineChipClassName(tone, className)
77
+ // Without somewhere to go it is a label, and a label is not a link.
78
+ if (href === undefined) {
79
+ return (
80
+ <span className={classes} {...(props as ComponentPropsWithRef<'span'>)}>
81
+ {body}
82
+ </span>
83
+ )
84
+ }
85
+ return (
86
+ <a href={href} className={classes} {...props}>
87
+ {body}
88
+ </a>
89
+ )
90
+ }
package/src/Link.mdx ADDED
@@ -0,0 +1,59 @@
1
+ import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
2
+ import * as LinkStories from './Link.stories'
3
+
4
+ <Meta of={LinkStories} />
5
+
6
+ # Link
7
+
8
+ A link, in one of four looks. It is a real anchor with a real address, and
9
+ the app decides how it navigates.
10
+
11
+ <Canvas of={LinkStories.AllVariants} />
12
+
13
+ ## When
14
+
15
+ - **`text`** — a link written inside text: the info colour, always
16
+ underlined, dimming on hover.
17
+ - **`quiet`** — a title or a time that is also a link. It takes the colour and
18
+ size of the text it sits in, and underlines on hover.
19
+ - **`underlined`** — a short "open it there" beside a note. It takes the
20
+ note's colour, is always underlined, and brightens on hover.
21
+ - **`plain`** — a box or a row that is a link as a whole. No look of its own:
22
+ what it wraps draws itself.
23
+ - `external` — another site or app: it opens in a new tab, with
24
+ `noopener noreferrer`.
25
+
26
+ ## When not
27
+
28
+ - An action — something happens, nothing is navigated to → **Button**.
29
+ - A row of a sidebar → **NavItem**. A tile of a rail → **RailItem**.
30
+ - The trail back up → **Breadcrumb**.
31
+ - A word standing for a person or a thing, drawn in a small box → **InlineChip**
32
+ with `href`.
33
+
34
+ ## How
35
+
36
+ ```tsx
37
+ import { Link } from '@estiva-app/ui'
38
+
39
+ <p className="text-body-2">
40
+ Read <Link href="https://example.com" external>the guide</Link> first.
41
+ </p>
42
+
43
+ <h3 className="text-body-2 font-semibold text-text-primary">
44
+ <Link href={itemUrl} variant="quiet" onClick={navigate}>Item one</Link>
45
+ </h3>
46
+ ```
47
+
48
+ - It renders a plain anchor and passes every anchor prop through. A router app
49
+ passes `onClick`, prevents the default and navigates; the `href` stays a real
50
+ address, so a modified click or a new tab still works.
51
+ - `quiet` and `underlined` set no size and no colour at rest: put them inside
52
+ the text whose look they should take.
53
+ - `plain` wraps a block: give the Link `className="block"` and let the child
54
+ draw the border, the fill and the hover.
55
+ - If your router also exports a `Link`, rename one of them on import.
56
+
57
+ ## Props
58
+
59
+ <Controls of={LinkStories.Text} />
@@ -0,0 +1,102 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { Link } from './Link'
3
+
4
+ const meta = {
5
+ title: 'Navigation/Link',
6
+ component: Link,
7
+ args: { href: '#', variant: 'text', external: false, children: 'a link' },
8
+ argTypes: {
9
+ variant: { control: 'inline-radio', options: ['text', 'quiet', 'underlined', 'plain'] },
10
+ },
11
+ } satisfies Meta<typeof Link>
12
+
13
+ export default meta
14
+ type Story = StoryObj<typeof meta>
15
+
16
+ /** Inside written text: the info colour, always underlined. Hover dims it. */
17
+ export const Text: Story = {
18
+ render: (args) => (
19
+ <p className="max-w-[480px] text-body-2 text-text-primary">
20
+ A sentence can hold <Link {...args} /> in the middle of it, and the words around it read on.
21
+ </p>
22
+ ),
23
+ }
24
+
25
+ /** A title, or a small time, that is also a link: it keeps its text's colour and size, and underlines on hover. */
26
+ export const Quiet: Story = {
27
+ args: { variant: 'quiet', children: 'Item one' },
28
+ render: (args) => (
29
+ <div className="flex flex-col gap-2">
30
+ <p className="text-body-2 font-semibold text-text-primary">
31
+ <Link {...args} />
32
+ </p>
33
+ <p className="text-caption text-text-secondary">
34
+ <Link {...args}>2:14 PM</Link>
35
+ </p>
36
+ </div>
37
+ ),
38
+ }
39
+
40
+ /** Beside a note, a short way to open the thing somewhere else: the note's colour, always underlined. Hover brightens it. */
41
+ export const Underlined: Story = {
42
+ args: { variant: 'underlined', external: true, children: 'Open it there ↗' },
43
+ render: (args) => (
44
+ <p className="max-w-[480px] text-caption text-text-secondary">
45
+ This can’t be shown here. <Link {...args} />
46
+ </p>
47
+ ),
48
+ }
49
+
50
+ /** No look of its own: what it wraps draws itself — a box, a row. */
51
+ export const Plain: Story = {
52
+ args: { variant: 'plain', children: undefined },
53
+ render: (args) => (
54
+ <Link {...args} className="block w-[280px]">
55
+ <div className="rounded-lg border border-border-default bg-bg-surface p-3 text-body-2 text-text-primary transition-colors hover:border-border-strong">
56
+ Item one
57
+ </div>
58
+ </Link>
59
+ ),
60
+ }
61
+
62
+ /** Another site or app: a new tab, and `noopener noreferrer`. It looks the same; the difference is where it opens. */
63
+ export const External: Story = {
64
+ args: { external: true, children: 'a page elsewhere' },
65
+ render: (args) => (
66
+ <p className="max-w-[480px] text-body-2 text-text-primary">
67
+ Read <Link {...args} /> in a new tab.
68
+ </p>
69
+ ),
70
+ }
71
+
72
+ /** The four looks, each where it belongs. */
73
+ export const AllVariants: Story = {
74
+ parameters: { controls: { disable: true } },
75
+ render: () => (
76
+ <div className="flex max-w-[480px] flex-col gap-4">
77
+ <p className="text-body-2 text-text-primary">
78
+ <span className="text-text-secondary">text — </span>a sentence with <Link href="#">a link</Link> in it.
79
+ </p>
80
+ <p className="text-body-2 font-semibold text-text-primary">
81
+ <span className="font-normal text-text-secondary">quiet — </span>
82
+ <Link href="#" variant="quiet">
83
+ Item one
84
+ </Link>
85
+ </p>
86
+ <p className="text-caption text-text-secondary">
87
+ underlined — This can’t be shown here.{' '}
88
+ <Link href="#" variant="underlined" external>
89
+ Open it there ↗
90
+ </Link>
91
+ </p>
92
+ <div className="flex flex-col gap-1">
93
+ <span className="text-body-2 text-text-secondary">plain —</span>
94
+ <Link href="#" variant="plain" className="block w-[280px]">
95
+ <div className="rounded-lg border border-border-default bg-bg-surface p-3 text-body-2 text-text-primary transition-colors hover:border-border-strong">
96
+ Item one
97
+ </div>
98
+ </Link>
99
+ </div>
100
+ </div>
101
+ ),
102
+ }
@@ -0,0 +1,100 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * What the Link page claims, pinned: it is a real anchor with a real address;
4
+ * `external` opens a new tab that cannot reach back; a router app takes the
5
+ * click through `onClick` and the address survives it; each look is its own
6
+ * class list and `plain` carries none; a ref reaches the anchor.
7
+ */
8
+ import { createRef } from 'react'
9
+ import { afterEach, describe, expect, it, vi } from 'vitest'
10
+ import { cleanup, render, screen } from '@testing-library/react'
11
+ import userEvent from '@testing-library/user-event'
12
+ import { Link } from './Link'
13
+
14
+ afterEach(cleanup)
15
+
16
+ const classesOf = (el: Element) => el.getAttribute('class')?.split(' ').filter(Boolean) ?? []
17
+
18
+ describe('Link', () => {
19
+ it('is an anchor with its address', () => {
20
+ render(<Link href="/documents">Documents</Link>)
21
+ const link = screen.getByRole('link', { name: 'Documents' })
22
+ expect(link.tagName).toBe('A')
23
+ expect(link.getAttribute('href')).toBe('/documents')
24
+ })
25
+
26
+ it('stays in the same tab unless it is external', () => {
27
+ render(<Link href="/documents">Documents</Link>)
28
+ const link = screen.getByRole('link', { name: 'Documents' })
29
+ expect(link.getAttribute('target')).toBeNull()
30
+ expect(link.getAttribute('rel')).toBeNull()
31
+ })
32
+
33
+ it('external opens a new tab that cannot reach back into this page', () => {
34
+ render(
35
+ <Link href="https://example.com" external>
36
+ Example
37
+ </Link>,
38
+ )
39
+ const link = screen.getByRole('link', { name: 'Example' })
40
+ expect(link.getAttribute('target')).toBe('_blank')
41
+ expect(link.getAttribute('rel')).toBe('noopener noreferrer')
42
+ })
43
+
44
+ it('a router app takes the click through onClick, and the href stays a real address', async () => {
45
+ const onClick = vi.fn((event: { preventDefault: () => void }) => event.preventDefault())
46
+ render(
47
+ <Link href="/documents" onClick={onClick}>
48
+ Documents
49
+ </Link>,
50
+ )
51
+ await userEvent.click(screen.getByRole('link', { name: 'Documents' }))
52
+ expect(onClick).toHaveBeenCalledTimes(1)
53
+ expect(screen.getByRole('link', { name: 'Documents' }).getAttribute('href')).toBe('/documents')
54
+ })
55
+
56
+ it('text is the info colour, always underlined, and is the default', () => {
57
+ render(<Link href="#">Label</Link>)
58
+ const classes = classesOf(screen.getByRole('link'))
59
+ expect(classes).toContain('text-info-default')
60
+ expect(classes).toContain('underline')
61
+ expect(classes).toContain('underline-offset-2')
62
+ })
63
+
64
+ it('quiet sets no colour and no size of its own, and underlines on hover', () => {
65
+ render(
66
+ <Link href="#" variant="quiet">
67
+ Label
68
+ </Link>,
69
+ )
70
+ expect(classesOf(screen.getByRole('link'))).toEqual(['hover:underline'])
71
+ })
72
+
73
+ it('underlined sets no colour at rest, is always underlined, and brightens on hover', () => {
74
+ render(
75
+ <Link href="#" variant="underlined">
76
+ Label
77
+ </Link>,
78
+ )
79
+ expect(classesOf(screen.getByRole('link'))).toEqual(['underline', 'underline-offset-2', 'hover:text-text-primary'])
80
+ })
81
+
82
+ it('plain carries no class at all, so what it wraps draws itself', () => {
83
+ render(
84
+ <Link href="#" variant="plain">
85
+ Label
86
+ </Link>,
87
+ )
88
+ expect(classesOf(screen.getByRole('link'))).toEqual([])
89
+ })
90
+
91
+ it('a ref reaches the anchor', () => {
92
+ const ref = createRef<HTMLAnchorElement>()
93
+ render(
94
+ <Link href="#" ref={ref}>
95
+ Label
96
+ </Link>,
97
+ )
98
+ expect(ref.current?.tagName).toBe('A')
99
+ })
100
+ })
package/src/Link.tsx ADDED
@@ -0,0 +1,58 @@
1
+ import type { ComponentPropsWithRef, ReactNode } from 'react'
2
+ import { cn } from './cn'
3
+
4
+ /**
5
+ * A link: an anchor with one of four looks, and nothing else of its own.
6
+ *
7
+ * It exists because both apps hand-wrote every link they have — 18 raw `<a>`
8
+ * on 13 September 2026, in three jobs (UIG-27). The looks are the ones those
9
+ * links already had, reduced by Katerina's ruling:
10
+ *
11
+ * - `text` — a link inside written text: the info colour, always underlined,
12
+ * dimming on hover. The look a link in a body of text already had.
13
+ * - `quiet` — a title or a timestamp that is also a link. It takes the colour
14
+ * and size of the text it sits in and underlines on hover. Two looks that
15
+ * differed only in their text became this one (her ruling, 13 September).
16
+ * - `underlined` — a short "open it elsewhere" beside a note. It takes the
17
+ * note's colour, is always underlined, and brightens on hover. A dotted
18
+ * and a solid version became this one, solid (her ruling, 13 September).
19
+ * - `plain` — no look at all: a card or a row that is a link draws itself.
20
+ *
21
+ * **Navigation is the app's.** The package cannot know a router, so like
22
+ * `NavItem` it renders a real anchor and passes every anchor prop through: a
23
+ * router app hands in `onClick`, prevents the default and navigates, and the
24
+ * `href` stays a real address for a modified click or a new tab.
25
+ */
26
+ export type LinkVariant = 'text' | 'quiet' | 'underlined' | 'plain'
27
+
28
+ const VARIANT_CLASSES: Record<LinkVariant, string> = {
29
+ text: 'text-info-default underline underline-offset-2 hover:opacity-80',
30
+ quiet: 'hover:underline',
31
+ underlined: 'underline underline-offset-2 hover:text-text-primary',
32
+ plain: '',
33
+ }
34
+
35
+ export interface LinkProps extends ComponentPropsWithRef<'a'> {
36
+ href: string
37
+ /** Default `text`. */
38
+ variant?: LinkVariant
39
+ /**
40
+ * Another site, or another app: opens in a new tab, with `noopener
41
+ * noreferrer` so the page it opens cannot reach back into this one.
42
+ */
43
+ external?: boolean
44
+ children: ReactNode
45
+ }
46
+
47
+ export function Link({ href, variant = 'text', external = false, className, children, ...props }: LinkProps) {
48
+ return (
49
+ <a
50
+ href={href}
51
+ {...(external ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
52
+ className={cn(VARIANT_CLASSES[variant], className)}
53
+ {...props}
54
+ >
55
+ {children}
56
+ </a>
57
+ )
58
+ }
package/src/Menu.test.tsx CHANGED
@@ -326,3 +326,23 @@ describe('rows on a bare MenuPanel', () => {
326
326
  expect(screen.queryByRole('group')).toBeNull()
327
327
  })
328
328
  })
329
+
330
+ /** As `Popover`: the padding is the content's, and a caller sets it there
331
+ * (PLAN Finding 60 — Peek's Later menu asked for `p-1` on `className` and got
332
+ * 12px from 0.12.6 on). */
333
+ describe('Menu, padding', () => {
334
+ it('takes a caller’s padding on contentClassName, instead of the 8px', async () => {
335
+ render(
336
+ <Menu trigger={<Button>Open</Button>} contentClassName="p-1">
337
+ <MenuItem label="Item one" onClick={() => {}} />
338
+ </Menu>,
339
+ )
340
+ await userEvent.click(screen.getByRole('button', { name: 'Open' }))
341
+ const row = await screen.findByRole('menuitem', { name: 'Item one' })
342
+ // The scrolling content is the box that carries the separator rule.
343
+ const content = row.closest('[class*="role=separator"]') as HTMLElement
344
+ const classes = content.className.split(/\s+/)
345
+ expect(classes).toContain('p-1')
346
+ expect(classes).not.toContain('p-2')
347
+ })
348
+ })
package/src/Menu.tsx CHANGED
@@ -142,8 +142,18 @@ export interface MenuProps {
142
142
  * closes the menu by itself, so this is only for content that is not one. */
143
143
  actionsRef?: RefObject<{ close: () => void; unmount: () => void } | null>
144
144
  children: ReactNode
145
- /** On the menu's surface — its width, its internal rhythm. */
145
+ /** On the menu's surface — its width. **Not its padding**: see
146
+ * `contentClassName`. */
146
147
  className?: string
148
+ /**
149
+ * The padding around the rows, as a class. Default `p-2`, 8px.
150
+ *
151
+ * It is on the scrolling content, not on the panel, so the scrollbar hugs the
152
+ * panel's edge (D63) — and so a padding class on `className` adds to it
153
+ * rather than replacing it. Peek's Later menu asked for `p-1` there and got
154
+ * 12px from `0.12.6` on (PLAN Finding 60). Set it here.
155
+ */
156
+ contentClassName?: string
147
157
  }
148
158
 
149
159
  /**
@@ -163,7 +173,7 @@ const VIEWPORT_PAD = 8
163
173
  const HOVER_OPEN_DELAY = 0
164
174
  const HOVER_CLOSE_DELAY = 150
165
175
 
166
- export function Menu({ trigger, align = 'left', openOnHover = false, open, onOpenChange, actionsRef, children, className }: MenuProps) {
176
+ export function Menu({ trigger, align = 'left', openOnHover = false, open, onOpenChange, actionsRef, children, className, contentClassName }: MenuProps) {
167
177
  return (
168
178
  <BaseMenu.Root
169
179
  open={open}
@@ -227,9 +237,11 @@ export function Menu({ trigger, align = 'left', openOnHover = false, open, onOpe
227
237
  * as tall as Floating UI allowed.
228
238
  *
229
239
  * A caller's `className` still lands on the panel, so a caller
230
- * asking for different padding needs `contentClassName` — which is
231
- * what `MenuPanel` is for when one is used on its own. */}
232
- <ScrollArea viewportClassName="max-h-[var(--available-height)]" contentClassName="flex flex-col p-2 [&>[role=separator]]:mx-0">
240
+ * asking for different padding needs `contentClassName`. That
241
+ * sentence was written at 0.12.6 and the prop was not: a `p-1` on
242
+ * `className` added 4px to these 8px instead of replacing them
243
+ * (PLAN Finding 60). The prop exists now. */}
244
+ <ScrollArea viewportClassName="max-h-[var(--available-height)]" contentClassName={cn('flex flex-col p-2 [&>[role=separator]]:mx-0', contentClassName)}>
233
245
  <MenuContext.Provider value={{ openOnHover }}>{children}</MenuContext.Provider>
234
246
  </ScrollArea>
235
247
  </BaseMenu.Popup>
@@ -3,7 +3,7 @@ import { Person } from './Person'
3
3
 
4
4
  /** A face beside a name — never a key. */
5
5
  const meta = {
6
- title: 'Primitives/Person',
6
+ title: 'Components/Person',
7
7
  component: Person,
8
8
  args: { name: 'Ana Duarte', size: 20 },
9
9
  } satisfies Meta<typeof Person>
@@ -3,7 +3,7 @@ import { PersonTrigger } from './PersonTrigger'
3
3
 
4
4
  /** The person, as the button that opens the account menu. The menu itself stays in the app. */
5
5
  const meta = {
6
- title: 'Primitives/PersonTrigger',
6
+ title: 'Components/PersonTrigger',
7
7
  component: PersonTrigger,
8
8
  args: { name: 'Ana Duarte', open: false, compact: false },
9
9
  } satisfies Meta<typeof PersonTrigger>
package/src/Popover.mdx CHANGED
@@ -64,8 +64,10 @@ import { Popover } from '@estiva-app/ui'
64
64
  force it. Leave them off and the panel keeps its own state.
65
65
  - `ariaLabel` names the panel. A panel with a visible heading can point at it
66
66
  with `aria-labelledby` instead.
67
- - Width, padding and internal rhythm are yours, through `className` the
68
- panel is a surface, not a layout.
67
+ - **Width goes on `className`, padding on `contentClassName`.** The padding
68
+ sits on the scrolling content so the scrollbar hugs the panel's edge, and a
69
+ padding class on `className` adds to it rather than replacing it. Default
70
+ 8px; **a toolbar asks for `contentClassName="p-1"`**.
69
71
 
70
72
  <Canvas of={PopoverStories.FromATrigger} />
71
73
 
@@ -1,6 +1,6 @@
1
1
  import type { Meta, StoryObj } from '@storybook/react-vite'
2
2
  import { IconBold, IconItalic, IconLink } from '@tabler/icons-react'
3
- import { useRef, useState, type KeyboardEvent, useCallback } from 'react'
3
+ import { useRef, useState, type KeyboardEvent } from 'react'
4
4
  import { Button } from './Button'
5
5
  import { MenuPanel } from './Menu'
6
6
  import { Popover } from './Popover'
@@ -105,7 +105,8 @@ export const AToolbar: Story = {
105
105
  when there is no room, which is the reason the placement is its job
106
106
  and not ours. */
107
107
  side="top"
108
- className="w-auto min-w-0 p-1"
108
+ className="w-auto min-w-0"
109
+ contentClassName="p-1"
109
110
  >
110
111
  {/* The strip is a `Toolbar`, so the whole row is ONE Tab stop and the
111
112
  arrow keys walk it — four stops before, one after. */}
@@ -234,17 +235,18 @@ export const Capped: Story = {
234
235
  parameters: { controls: { disable: true }, layout: 'fullscreen' },
235
236
  render: function Capped() {
236
237
  /* Anchored and open, so the cap is the thing you see rather than a button
237
- you have to press first. A rect is all an anchor needs. */
238
- const [rect, setRect] = useState<DOMRect | null>(null)
239
- const mark = useCallback((el: HTMLDivElement | null) => {
240
- setRect(el ? el.getBoundingClientRect() : null)
241
- }, [])
238
+ you have to press first. Anchored on the ELEMENT, held in state from a
239
+ callback ref: an element is re-measured. It used to be a rect read once
240
+ in the ref, and arriving at this story from another one read it before
241
+ the canvas was laid out — 0 × 0 at the corner, a 0px panel, only the
242
+ line of text showing (PLAN Finding 61). */
243
+ const [marker, setMarker] = useState<HTMLDivElement | null>(null)
242
244
  return (
243
245
  <div className="flex h-[420px] w-full items-center justify-center">
244
- <div ref={mark} className="text-body-2 text-text-secondary">
246
+ <div ref={setMarker} className="text-body-2 text-text-secondary">
245
247
  twenty rows, capped at 160px
246
248
  </div>
247
- <Popover anchor={rect} open ariaLabel="A long panel" className="w-[240px]" maxHeight="max-h-[160px]">
249
+ <Popover anchor={marker} open ariaLabel="A long panel" className="w-[240px]" maxHeight="max-h-[160px]">
248
250
  {Array.from({ length: 20 }, (_, i) => (
249
251
  <span key={i} className="text-body-2 text-text-primary py-1">
250
252
  Row {i + 1}