@estiva-app/ui 0.54.0 → 0.56.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/BlockHandle.d.ts +47 -0
- package/dist/BlockHandle.d.ts.map +1 -0
- package/dist/CollapsibleSection.d.ts +7 -1
- package/dist/CollapsibleSection.d.ts.map +1 -1
- package/dist/ContainerHeader.d.ts +7 -1
- package/dist/ContainerHeader.d.ts.map +1 -1
- package/dist/EditorBlockHandle.d.ts +57 -0
- package/dist/EditorBlockHandle.d.ts.map +1 -0
- package/dist/IconButton.d.ts.map +1 -1
- package/dist/ListColumn.d.ts +2 -0
- package/dist/ListColumn.d.ts.map +1 -1
- package/dist/Menu.d.ts.map +1 -1
- package/dist/NavItem.d.ts.map +1 -1
- package/dist/NavTree.d.ts +9 -1
- package/dist/NavTree.d.ts.map +1 -1
- package/dist/PersonTrigger.d.ts.map +1 -1
- package/dist/SectionHeader.d.ts +22 -3
- package/dist/SectionHeader.d.ts.map +1 -1
- package/dist/SectionLabel.d.ts.map +1 -1
- package/dist/editor.d.ts +1 -0
- package/dist/editor.d.ts.map +1 -1
- package/dist/editor.js +127 -11
- package/dist/editor.js.map +4 -4
- package/dist/gates/index.js +1 -1
- package/dist/gates/index.js.map +2 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +831 -624
- package/dist/index.js.map +4 -4
- package/dist/looks.d.ts +4 -0
- package/dist/looks.d.ts.map +1 -1
- package/package.json +1 -1
- package/registry.json +302 -23
- package/src/BlockHandle.mdx +58 -0
- package/src/BlockHandle.stories.tsx +44 -0
- package/src/BlockHandle.test.tsx +216 -0
- package/src/BlockHandle.tsx +220 -0
- package/src/CollapsibleSection.stories.tsx +14 -1
- package/src/CollapsibleSection.tsx +10 -4
- package/src/ContainerHeader.mdx +4 -0
- package/src/ContainerHeader.stories.tsx +14 -0
- package/src/ContainerHeader.test.tsx +8 -0
- package/src/ContainerHeader.tsx +34 -6
- package/src/EditorBlockHandle.mdx +65 -0
- package/src/EditorBlockHandle.stories.tsx +81 -0
- package/src/EditorBlockHandle.tsx +181 -0
- package/src/IconButton.tsx +2 -1
- package/src/ListColumn.tsx +4 -2
- package/src/Menu.tsx +5 -4
- package/src/NavItem.test.tsx +2 -2
- package/src/NavItem.tsx +21 -12
- package/src/NavTree.mdx +16 -1
- package/src/NavTree.stories.tsx +49 -2
- package/src/NavTree.test.tsx +7 -0
- package/src/NavTree.tsx +21 -4
- package/src/PersonTrigger.tsx +6 -5
- package/src/SectionHeader.mdx +6 -0
- package/src/SectionHeader.stories.tsx +18 -0
- package/src/SectionHeader.test.tsx +26 -0
- package/src/SectionHeader.tsx +99 -23
- package/src/SectionLabel.tsx +6 -3
- package/src/Sidebar.tsx +1 -1
- package/src/editor.ts +1 -0
- package/src/eslint/no-copied-look.test.ts +2 -2
- package/src/gates/app-checks.ts +1 -1
- package/src/index.ts +1 -0
- package/src/looks.ts +6 -0
- package/src/registry/registry.test.ts +5 -5
- package/stories/Choosing.mdx +2 -0
package/src/NavTree.tsx
CHANGED
|
@@ -52,6 +52,10 @@ export interface NavTreeGroup {
|
|
|
52
52
|
trailing?: ReactNode
|
|
53
53
|
/** Beside the title, shown on hover or focus: "Open", typically. */
|
|
54
54
|
actions?: SectionAction[]
|
|
55
|
+
/** The group's "More options" menu, after its actions: `MenuItem`s — a folder's Rename, Archive. */
|
|
56
|
+
menu?: ReactNode
|
|
57
|
+
/** 16px, before the title — a folder's; the arrow takes its place on hover. */
|
|
58
|
+
icon?: ReactNode
|
|
55
59
|
defaultOpen?: boolean
|
|
56
60
|
/** The caller owns whether the group is open. */
|
|
57
61
|
open?: boolean
|
|
@@ -146,6 +150,8 @@ function Group({ group, selected, onSelect, storageKey }: { group: NavTreeGroup
|
|
|
146
150
|
loading={group.nodes === null}
|
|
147
151
|
trailing={group.trailing}
|
|
148
152
|
actions={group.actions}
|
|
153
|
+
menu={group.menu}
|
|
154
|
+
icon={group.icon}
|
|
149
155
|
defaultOpen={group.defaultOpen ?? holds(group.nodes, selected)}
|
|
150
156
|
open={group.open}
|
|
151
157
|
onOpenChange={group.onOpenChange}
|
|
@@ -167,6 +173,10 @@ export interface NavTreeSectionProps {
|
|
|
167
173
|
trailing?: ReactNode
|
|
168
174
|
/** Beside the title, shown on hover or focus: "Open", typically. */
|
|
169
175
|
actions?: SectionAction[]
|
|
176
|
+
/** The group's "More options" menu, after its actions: `MenuItem`s. */
|
|
177
|
+
menu?: ReactNode
|
|
178
|
+
/** 16px, before the title; the arrow takes its place on hover. */
|
|
179
|
+
icon?: ReactNode
|
|
170
180
|
/** Open unless told otherwise. */
|
|
171
181
|
defaultOpen?: boolean
|
|
172
182
|
/** The caller owns whether the group is open. */
|
|
@@ -189,13 +199,17 @@ export interface NavTreeSectionProps {
|
|
|
189
199
|
* `groups`; an app whose groups each read their own data draws them itself,
|
|
190
200
|
* as `NavTree`'s children.
|
|
191
201
|
*/
|
|
192
|
-
export function NavTreeSection({ id, title, message, loading = false, trailing, actions, defaultOpen = true, open, onOpenChange, storageKey, onIntent, children }: NavTreeSectionProps) {
|
|
202
|
+
export function NavTreeSection({ id, title, message, loading = false, trailing, actions, menu, icon, defaultOpen = true, open, onOpenChange, storageKey, onIntent, children }: NavTreeSectionProps) {
|
|
193
203
|
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
|
|
194
204
|
useEffect(() => () => clearTimeout(timer.current), [])
|
|
195
205
|
const intent = onIntent
|
|
196
206
|
return (
|
|
197
207
|
<div
|
|
198
|
-
|
|
208
|
+
// 4px between groups, none above the first: a folded group's heading
|
|
209
|
+
// sits 38px from the next (Katerina, 5 October: 42 was too far from rows
|
|
210
|
+
// 33 apart; halved), and the list starts 16px under its header, as every
|
|
211
|
+
// list column does (on the first group it read 20, Topics against People).
|
|
212
|
+
className="mt-1 shrink-0 first:mt-0"
|
|
199
213
|
data-nav-group={id}
|
|
200
214
|
onPointerEnter={intent ? () => { clearTimeout(timer.current); timer.current = setTimeout(intent, NAV_TREE_INTENT_MS) } : undefined}
|
|
201
215
|
onPointerLeave={intent ? () => clearTimeout(timer.current) : undefined}
|
|
@@ -210,8 +224,11 @@ export function NavTreeSection({ id, title, message, loading = false, trailing,
|
|
|
210
224
|
storageKey={storageKey}
|
|
211
225
|
trailing={trailing}
|
|
212
226
|
actions={actions}
|
|
213
|
-
|
|
214
|
-
|
|
227
|
+
menu={menu}
|
|
228
|
+
icon={icon}
|
|
229
|
+
className="shrink-0"
|
|
230
|
+
// 2px between rows, as a list column's and a menu's (Katerina, 5 October).
|
|
231
|
+
contentClassName="gap-0.5"
|
|
215
232
|
>
|
|
216
233
|
{message !== undefined ? <Line message={message} /> : loading ? <SkeletonList rows={3} /> : children}
|
|
217
234
|
</CollapsibleSection>
|
package/src/PersonTrigger.tsx
CHANGED
|
@@ -2,6 +2,7 @@ import type { ComponentPropsWithRef } from 'react'
|
|
|
2
2
|
import { Button as BaseButton } from '@base-ui/react/button'
|
|
3
3
|
import { IconChevronDown } from '@tabler/icons-react'
|
|
4
4
|
import { cn } from './cn'
|
|
5
|
+
import { MENU_TRIGGER_CLASSES } from './looks'
|
|
5
6
|
import { Avatar } from './Avatar'
|
|
6
7
|
import { Person, type PersonProps } from './Person'
|
|
7
8
|
|
|
@@ -66,13 +67,13 @@ export function PersonTrigger({ name, picture, fallback, size, open = false, com
|
|
|
66
67
|
aria-haspopup="menu"
|
|
67
68
|
aria-expanded={open}
|
|
68
69
|
className={cn(
|
|
69
|
-
'flex h-8 min-h-8
|
|
70
|
+
'flex h-8 min-h-8 items-center gap-1.5 pl-1.5 pr-1.5 text-body-2 text-text-primary',
|
|
71
|
+
MENU_TRIGGER_CLASSES,
|
|
70
72
|
open && 'bg-bg-hover',
|
|
71
73
|
// Under a `Menu` the open state is Base UI's, not a prop: the trigger
|
|
72
|
-
// carries `data-popup-open` while its menu is up
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
'data-[popup-open]:bg-bg-hover',
|
|
74
|
+
// carries `data-popup-open` while its menu is up (MENU_TRIGGER_CLASSES
|
|
75
|
+
// holds the fill for it), and sets its own `aria-expanded`; `open`
|
|
76
|
+
// holds it for a caller that drives it.
|
|
76
77
|
className,
|
|
77
78
|
)}
|
|
78
79
|
{...props}
|
package/src/SectionHeader.mdx
CHANGED
|
@@ -24,6 +24,12 @@ title the toggle — that is where a section folds.
|
|
|
24
24
|
|
|
25
25
|
<Canvas of={SectionHeaderStories.PersistentActions} />
|
|
26
26
|
|
|
27
|
+
- `menu` gives the section a "More options" ⋮ after its actions, with what
|
|
28
|
+
acts on the whole section — a folder's Rename, Archive. It is **NavItem**'s
|
|
29
|
+
⋮, revealed with the actions, and stays while its menu is open.
|
|
30
|
+
|
|
31
|
+
<Canvas of={SectionHeaderStories.WithMenu} />
|
|
32
|
+
|
|
27
33
|
- `look="quiet"`: a small grey label over a list whose rows are rows, not
|
|
28
34
|
headings — "Folders" over the folders — with its action held on screen.
|
|
29
35
|
It never lights up under the pointer; its button does.
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
2
|
import { IconPlus, IconSortDescending } from '@tabler/icons-react'
|
|
3
3
|
import { Chip } from './Chip'
|
|
4
|
+
import { MenuItem, MenuSeparator } from './Menu'
|
|
4
5
|
import { SectionHeader } from './SectionHeader'
|
|
5
6
|
|
|
6
7
|
/** The 32px row a section starts with. Hover it: the row fills, and its actions appear. A section that folds is CollapsibleSection, whose header this is. */
|
|
@@ -49,6 +50,23 @@ export const StillOnHover: Story = {
|
|
|
49
50
|
},
|
|
50
51
|
}
|
|
51
52
|
|
|
53
|
+
/** `menu`: the section's "More options", after its actions and revealed with them. The row keeps its hover look while the menu is open. */
|
|
54
|
+
export const WithMenu: Story = {
|
|
55
|
+
args: {
|
|
56
|
+
chevron: true,
|
|
57
|
+
isExpanded: true,
|
|
58
|
+
onToggle: () => {},
|
|
59
|
+
menu: (
|
|
60
|
+
<>
|
|
61
|
+
<MenuItem label="Add" />
|
|
62
|
+
<MenuItem label="Rename" />
|
|
63
|
+
<MenuSeparator />
|
|
64
|
+
<MenuItem label="Delete" destructive />
|
|
65
|
+
</>
|
|
66
|
+
),
|
|
67
|
+
},
|
|
68
|
+
}
|
|
69
|
+
|
|
52
70
|
/** A count beside the title, held on screen while the actions come and go. */
|
|
53
71
|
export const WithTrailing: Story = {
|
|
54
72
|
args: {
|
|
@@ -7,7 +7,9 @@
|
|
|
7
7
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
8
8
|
import { cleanup, render, screen } from '@testing-library/react'
|
|
9
9
|
import userEvent from '@testing-library/user-event'
|
|
10
|
+
import { MenuItem } from './Menu'
|
|
10
11
|
import { SectionHeader } from './SectionHeader'
|
|
12
|
+
import { UnreadDot } from './UnreadDot'
|
|
11
13
|
|
|
12
14
|
afterEach(cleanup)
|
|
13
15
|
|
|
@@ -71,4 +73,28 @@ describe('SectionHeader, the trailing slot', () => {
|
|
|
71
73
|
// Not part of the hover reveal either — a count is information, not an affordance.
|
|
72
74
|
expect(count.closest('.opacity-0')).toBe(null)
|
|
73
75
|
})
|
|
76
|
+
|
|
77
|
+
it('a dot sits in the last button’s place and steps aside for it; a count stays beside the buttons', () => {
|
|
78
|
+
const { container } = render(
|
|
79
|
+
<SectionHeader title="Section" trailing={<UnreadDot />} actions={[{ icon: <span />, tooltip: 'Open', onClick: () => {} }]} menu={<MenuItem label="Rename" />} />,
|
|
80
|
+
)
|
|
81
|
+
const dot = container.querySelector('[data-unread]')!
|
|
82
|
+
// Inside the buttons' box, over the last one — not a slot of its own before them.
|
|
83
|
+
expect(dot.closest('[class~="group/acts"]')).not.toBe(null)
|
|
84
|
+
expect(dot.parentElement!.className).toContain('group-hover:opacity-0')
|
|
85
|
+
expect(screen.getByRole('button', { name: 'More options for Section' })).not.toBeNull()
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('an icon shows at rest, and folding, the arrow is in its place', () => {
|
|
89
|
+
const { container } = render(<SectionHeader title="Section" chevron icon={<span data-icon />} onToggle={() => {}} />)
|
|
90
|
+
const icon = container.querySelector('[data-icon]')!
|
|
91
|
+
expect(icon.parentElement!.className).toContain('group-hover:opacity-0')
|
|
92
|
+
// The arrow is the icon's sibling, hidden until the pointer comes.
|
|
93
|
+
expect(icon.parentElement!.parentElement!.querySelector('svg')!.getAttribute('class')).toContain('group-hover:opacity-100')
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('a dot with no buttons sits on their axis all the same', () => {
|
|
97
|
+
const { container } = render(<SectionHeader title="Section" trailing={<UnreadDot />} />)
|
|
98
|
+
expect(container.querySelector('[data-unread]')!.parentElement!.className).toContain('-mr-1')
|
|
99
|
+
})
|
|
74
100
|
})
|
package/src/SectionHeader.tsx
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import type
|
|
2
|
-
import { IconChevronRight } from '@tabler/icons-react'
|
|
1
|
+
import { isValidElement, type ReactNode } from 'react'
|
|
2
|
+
import { IconChevronRight, IconDotsVertical } from '@tabler/icons-react'
|
|
3
3
|
import { useRender } from '@base-ui/react/use-render'
|
|
4
4
|
import { cn } from './cn'
|
|
5
5
|
import { SIDEBAR_ROW_CLASSES, SIDEBAR_ROW_TEXT_CLASSES } from './looks'
|
|
6
6
|
import { IconButton } from './IconButton'
|
|
7
|
+
import { Menu } from './Menu'
|
|
7
8
|
import { SectionLabel } from './SectionLabel'
|
|
9
|
+
import { UnreadDot } from './UnreadDot'
|
|
8
10
|
|
|
9
11
|
/**
|
|
10
12
|
* The 32px row a section starts with (Peek's SectionHeader, 2026-09-01):
|
|
@@ -45,10 +47,19 @@ export interface SectionHeaderProps {
|
|
|
45
47
|
title: string
|
|
46
48
|
/** Collapsible: draws the chevron and makes the title a button that toggles. */
|
|
47
49
|
chevron?: boolean
|
|
50
|
+
/**
|
|
51
|
+
* 16px, stroke 1.5, before the title — a folder's (Katerina, 5 October:
|
|
52
|
+
* Peek's Folders, the standard heading with a folder icon). With `chevron`
|
|
53
|
+
* the icon is what shows at rest and the arrow takes its place under the
|
|
54
|
+
* pointer or the keyboard, as a NavItem's icon turns into its arrow.
|
|
55
|
+
*/
|
|
56
|
+
icon?: ReactNode
|
|
48
57
|
isExpanded?: boolean
|
|
49
58
|
onToggle?: () => void
|
|
50
59
|
/**
|
|
51
|
-
* Beside the title and always visible, before the actions — a count.
|
|
60
|
+
* Beside the title and always visible, before the actions — a count. An
|
|
61
|
+
* `UnreadDot` instead sits in the last action's place (the ⋮'s, with a
|
|
62
|
+
* `menu`) and steps aside while the actions show, as a NavItem's dot does.
|
|
52
63
|
*
|
|
53
64
|
* The actions come and go with the hover; this does not, because a count is
|
|
54
65
|
* information rather than an affordance. Peek's Screener header carries its
|
|
@@ -58,6 +69,16 @@ export interface SectionHeaderProps {
|
|
|
58
69
|
trailing?: ReactNode
|
|
59
70
|
/** Right-aligned, in the order given. */
|
|
60
71
|
actions?: SectionAction[]
|
|
72
|
+
/**
|
|
73
|
+
* The heading's "More options" menu: `MenuItem`s — what acts on the whole
|
|
74
|
+
* section, a folder's Rename or Archive (Katerina, 5 October: Peek's Folders
|
|
75
|
+
* page goes, and its folder actions move onto the folder's heading). The ⋮
|
|
76
|
+
* is NavItem's, after the actions and revealed with them; it stays while its
|
|
77
|
+
* menu is open.
|
|
78
|
+
*/
|
|
79
|
+
menu?: ReactNode
|
|
80
|
+
/** The ⋮'s accessible name. Default "More options for" the title. */
|
|
81
|
+
menuLabel?: string
|
|
61
82
|
/** `hover` reveals the actions while the row is hovered or focused; `always` keeps them. */
|
|
62
83
|
showActions?: 'hover' | 'always'
|
|
63
84
|
/**
|
|
@@ -82,23 +103,51 @@ export interface SectionHeaderProps {
|
|
|
82
103
|
className?: string
|
|
83
104
|
}
|
|
84
105
|
|
|
85
|
-
export function SectionHeader({ title, chevron = false, isExpanded = true, onToggle, trailing, actions, showActions = 'hover', hover = 'fill', look = 'heading', render, className }: SectionHeaderProps) {
|
|
106
|
+
export function SectionHeader({ title, chevron = false, icon, isExpanded = true, onToggle, trailing, actions, menu, menuLabel, showActions = 'hover', hover = 'fill', look = 'heading', render, className }: SectionHeaderProps) {
|
|
86
107
|
const row = look === 'row'
|
|
108
|
+
/*
|
|
109
|
+
One arrow for everything that folds, a NavItem's 16px, and the title 8px
|
|
110
|
+
after it as a row's label is (Katerina, 5 October: a folder heading and
|
|
111
|
+
the rows under it fold with one arrow; then every folding heading, Peek's
|
|
112
|
+
Starred too). It was 12px with a 4px gap; with an icon, the arrow now
|
|
113
|
+
fills the icon's place exactly.
|
|
114
|
+
*/
|
|
115
|
+
const acts = (actions && actions.length > 0) || menu != null
|
|
116
|
+
// A dot sits in the place of the last button and steps aside while the
|
|
117
|
+
// buttons show — the rows' dot and ⋮ share a place the same way (Katerina,
|
|
118
|
+
// 5 October). A count stays beside them: it is information (09-09).
|
|
119
|
+
const dot = isValidElement(trailing) && trailing.type === UnreadDot
|
|
120
|
+
const shared = acts && showActions === 'hover' && dot
|
|
121
|
+
const reveal = showActions === 'hover' && 'opacity-0 transition-opacity group-hover:opacity-100 has-[:focus-visible]:opacity-100 has-[[aria-expanded=true]]:opacity-100'
|
|
87
122
|
const titleElement = useRender({
|
|
88
123
|
render: render ?? (chevron ? <button type="button" onClick={onToggle} aria-expanded={isExpanded} /> : <span />),
|
|
89
124
|
props: {
|
|
90
125
|
// `text-left`: a button centres its text. `h-full` and `flex-1`: the
|
|
91
126
|
// whole row up to the actions is the hit target, as it was when the
|
|
92
127
|
// row itself carried the click.
|
|
93
|
-
|
|
128
|
+
// An icon is a row's: NavItem's 8px after it.
|
|
129
|
+
className: cn('flex h-full min-w-0 flex-1 items-center text-left', row || chevron || icon != null ? 'gap-2' : 'gap-1'),
|
|
94
130
|
children: (
|
|
95
131
|
<>
|
|
96
|
-
{
|
|
97
|
-
<
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
132
|
+
{icon != null ? (
|
|
133
|
+
<span className="relative flex size-4 shrink-0 items-center justify-center text-text-secondary">
|
|
134
|
+
<span className={cn('flex', chevron && 'transition-opacity group-hover:opacity-0 group-has-[:focus-visible]:opacity-0')}>{icon}</span>
|
|
135
|
+
{chevron && (
|
|
136
|
+
<IconChevronRight
|
|
137
|
+
size={16}
|
|
138
|
+
stroke={1.5}
|
|
139
|
+
className={cn('absolute opacity-0 transition-[opacity,transform] duration-150 group-hover:opacity-100 group-has-[:focus-visible]:opacity-100', isExpanded && 'rotate-90')}
|
|
140
|
+
/>
|
|
141
|
+
)}
|
|
142
|
+
</span>
|
|
143
|
+
) : (
|
|
144
|
+
chevron && (
|
|
145
|
+
<IconChevronRight
|
|
146
|
+
size={16}
|
|
147
|
+
stroke={1.5}
|
|
148
|
+
className={cn('shrink-0 transition-transform duration-150', !row && 'text-text-secondary', isExpanded && 'rotate-90')}
|
|
149
|
+
/>
|
|
150
|
+
)
|
|
102
151
|
)}
|
|
103
152
|
{look === 'quiet' ? (
|
|
104
153
|
<span className="min-w-0 truncate text-caption text-text-muted">{title}</span>
|
|
@@ -124,7 +173,10 @@ export function SectionHeader({ title, chevron = false, isExpanded = true, onTog
|
|
|
124
173
|
// lights up, a fixed heading over rows does not (2026-09-09, the
|
|
125
174
|
// Sidebar's fixed group).
|
|
126
175
|
// A quiet label never lights up: it is a label; its button does.
|
|
127
|
-
hover === 'fill' && look !== 'quiet' && (chevron ||
|
|
176
|
+
hover === 'fill' && look !== 'quiet' && (chevron || acts) && 'hover:bg-bg-hover',
|
|
177
|
+
// While its menu is open the pointer is on the menu, not the row: the
|
|
178
|
+
// row keeps its hover look, as a NavItem's does.
|
|
179
|
+
hover === 'fill' && look !== 'quiet' && menu != null && 'has-[[data-section-menu]_[aria-expanded=true]]:bg-bg-hover',
|
|
128
180
|
className,
|
|
129
181
|
)}
|
|
130
182
|
>
|
|
@@ -132,23 +184,47 @@ export function SectionHeader({ title, chevron = false, isExpanded = true, onTog
|
|
|
132
184
|
{/* Outside the title button, like the actions: a chip inside a button
|
|
133
185
|
would be part of the button's accessible name and part of its hit
|
|
134
186
|
target, and the count is neither. */}
|
|
135
|
-
{trailing != null &&
|
|
136
|
-
|
|
187
|
+
{trailing != null && !shared && (
|
|
188
|
+
// A dot with no buttons to share a place with still sits on their
|
|
189
|
+
// axis — the rows' dots and ⋮ (`-mr-1`, as the buttons are pulled).
|
|
190
|
+
<div className={cn('flex shrink-0 items-center', dot && !acts && '-mr-1')}>{trailing}</div>
|
|
191
|
+
)}
|
|
192
|
+
{acts && (
|
|
137
193
|
<div
|
|
138
194
|
// `-mr-1`: an IconButton is 24px around a 16px icon, so at the row's
|
|
139
195
|
// `px-2` its icon ended 12px from the edge while a NavItem's number
|
|
140
196
|
// ends 8px from it; pulled 4px, the icon and the number share a
|
|
141
197
|
// right edge (Katerina, 2026-09-09; measured 4px off, then 0).
|
|
142
|
-
className=
|
|
143
|
-
'-mr-1 flex shrink-0 items-center gap-1',
|
|
144
|
-
showActions === 'hover' && 'opacity-0 transition-opacity focus-within:opacity-100 group-hover:opacity-100',
|
|
145
|
-
)}
|
|
198
|
+
className="group/acts relative -mr-1 flex shrink-0 items-center"
|
|
146
199
|
>
|
|
147
|
-
{
|
|
148
|
-
|
|
149
|
-
{action.
|
|
150
|
-
|
|
151
|
-
|
|
200
|
+
<div className={cn('flex items-center gap-1', reveal)}>
|
|
201
|
+
{actions?.map((action) => (
|
|
202
|
+
<IconButton key={action.tooltip} tooltip={action.tooltip} aria-label={action.tooltip} onClick={action.onClick}>
|
|
203
|
+
{action.icon}
|
|
204
|
+
</IconButton>
|
|
205
|
+
))}
|
|
206
|
+
{menu != null && (
|
|
207
|
+
<div data-section-menu className="flex items-center">
|
|
208
|
+
<Menu
|
|
209
|
+
align="right"
|
|
210
|
+
trigger={
|
|
211
|
+
<IconButton tooltip="More options" aria-label={menuLabel ?? `More options for ${title}`}>
|
|
212
|
+
<IconDotsVertical size={16} stroke={1.5} />
|
|
213
|
+
</IconButton>
|
|
214
|
+
}
|
|
215
|
+
>
|
|
216
|
+
{menu}
|
|
217
|
+
</Menu>
|
|
218
|
+
</div>
|
|
219
|
+
)}
|
|
220
|
+
</div>
|
|
221
|
+
{shared && (
|
|
222
|
+
// The dot in the last button's 24px place, on the axis of its icon;
|
|
223
|
+
// gone while the buttons show.
|
|
224
|
+
<div className="pointer-events-none absolute inset-y-0 right-0 flex w-6 items-center justify-center transition-opacity group-hover:opacity-0 group-has-[:focus-visible]/acts:opacity-0 group-has-[[aria-expanded=true]]/acts:opacity-0">
|
|
225
|
+
{trailing}
|
|
226
|
+
</div>
|
|
227
|
+
)}
|
|
152
228
|
</div>
|
|
153
229
|
)}
|
|
154
230
|
</div>
|
package/src/SectionLabel.tsx
CHANGED
|
@@ -45,9 +45,12 @@ export function SectionLabel({
|
|
|
45
45
|
return (
|
|
46
46
|
<span
|
|
47
47
|
className={cn(
|
|
48
|
-
'text-h5 leading-3
|
|
49
|
-
//
|
|
50
|
-
//
|
|
48
|
+
'text-h5 leading-3',
|
|
49
|
+
// Ship's type in every theme but Leaf, whose section labels are the
|
|
50
|
+
// micro-label (Katerina, 28 September) unless the group folds (2
|
|
51
|
+
// October). Signal dropped its micro-label on 5 October: every one of
|
|
52
|
+
// Peek's section labels is Ship's type, folding or not, so Desk's
|
|
53
|
+
// Screener and its Urgent and Open work read as one.
|
|
51
54
|
!folds && 'leaf:font-mono leaf:text-small leaf:uppercase leaf:tracking-widest',
|
|
52
55
|
tone === 'secondary' ? 'text-text-secondary' : tone === 'muted' ? 'text-text-muted' : 'text-text-primary',
|
|
53
56
|
truncate && 'min-w-0 truncate',
|
package/src/Sidebar.tsx
CHANGED
|
@@ -35,7 +35,7 @@ export function Sidebar({ 'aria-label': ariaLabel = 'Workspace', children, class
|
|
|
35
35
|
{/* The rows scroll in a ScrollArea: the bar takes no width, so the
|
|
36
36
|
column's padding reads the same with a long list as with a short
|
|
37
37
|
one (Katerina, 2026-09-08). The padding and the gap are the box's. */}
|
|
38
|
-
<ScrollArea className="min-h-0 flex-1" contentClassName="flex flex-col gap-
|
|
38
|
+
<ScrollArea className="min-h-0 flex-1" contentClassName="flex flex-col gap-0.5 px-2.5 py-3">
|
|
39
39
|
{children}
|
|
40
40
|
</ScrollArea>
|
|
41
41
|
</nav>
|
package/src/editor.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
export { KeptSelection, SelectionToolbar, normalizeHref, type MarkId, type SelectionToolbarProps } from './SelectionToolbar'
|
|
11
11
|
export { suggestionPopup, isSuggestionActive, isSuggestionOpen, type SuggestionPopupOptions } from './suggestionPopup'
|
|
12
|
+
export { EditorBlockHandle, blockDropCursor, deleteBlock, moveBlock, duplicateBlock, startBlockDrag, type EditorBlockHandleProps, type BlockTurnInto } from './EditorBlockHandle'
|
|
12
13
|
export {
|
|
13
14
|
PersonMention,
|
|
14
15
|
UrgentPersonMention,
|
|
@@ -27,7 +27,7 @@ const write = (path: string, text: string) => {
|
|
|
27
27
|
mkdirSync(dirname(join(root, path)), { recursive: true })
|
|
28
28
|
writeFileSync(join(root, path), text)
|
|
29
29
|
}
|
|
30
|
-
const SECTION_LABEL = 'text-h5 leading-3
|
|
30
|
+
const SECTION_LABEL = 'text-h5 leading-3 leaf:font-mono leaf:text-small leaf:uppercase leaf:tracking-widest text-text-secondary'
|
|
31
31
|
const registry = (entries: { name: string; sourceFile: string; looks: string[] }[]) => JSON.stringify({ schemaVersion: 2, entries })
|
|
32
32
|
write('app/package.json', '{ "name": "app" }')
|
|
33
33
|
write(
|
|
@@ -78,7 +78,7 @@ tester.run('no-copied-look', noCopiedLook, {
|
|
|
78
78
|
{
|
|
79
79
|
name: 'the same look, the words in another order and one class more',
|
|
80
80
|
filename: app,
|
|
81
|
-
code: probe('<span className="text-text-secondary
|
|
81
|
+
code: probe('<span className="text-text-secondary leaf:tracking-widest leaf:uppercase text-h5 leaf:text-small leaf:font-mono leading-3 shrink-0">x</span>'),
|
|
82
82
|
errors: [{ messageId: 'usePart' }],
|
|
83
83
|
},
|
|
84
84
|
{
|
package/src/gates/app-checks.ts
CHANGED
|
@@ -235,7 +235,7 @@ export function appChecks(h: GateHelpers, { app = '.', page, chain = { ref: 'UIG
|
|
|
235
235
|
{ what: 'a title= is an error naming WithTooltip', run: probe(`import { Button } from '@estiva-app/ui'\n${component('<Button title="Delete">x</Button>')}`, 'error', 'WithTooltip') },
|
|
236
236
|
]),
|
|
237
237
|
ticket('UIG-25', [
|
|
238
|
-
{ what: "SectionLabel's class list, typed by hand, is a warning", run: probe(component('<span className="text-h5 leading-3
|
|
238
|
+
{ what: "SectionLabel's class list, typed by hand, is a warning", run: probe(component('<span className="text-h5 leading-3 leaf:font-mono leaf:text-small leaf:uppercase leaf:tracking-widest text-text-secondary">Label</span>'), 'warning', 'SectionLabel') },
|
|
239
239
|
]),
|
|
240
240
|
ticket('UIG-27', [
|
|
241
241
|
{ what: 'the installed package has Link', run: () => h.contains(installed, /\bLink\b/, 'the installed @estiva-app/ui exports Link') },
|
package/src/index.ts
CHANGED
|
@@ -14,6 +14,7 @@ export { AttachmentCard, type AttachmentCardProps, type AttachmentCardState } fr
|
|
|
14
14
|
export { Avatar, hueFor, initialsFor, type AvatarProps } from './Avatar'
|
|
15
15
|
export { AvatarGroup, type AvatarGroupMember, type AvatarGroupProps } from './AvatarGroup'
|
|
16
16
|
export { Banner, type BannerProps, type BannerTone } from './Banner'
|
|
17
|
+
export { BlockHandle, blockAtY, type BlockHandleProps, type BlockHandleEditing, type HandleBlock } from './BlockHandle'
|
|
17
18
|
export { Button, type ButtonProps, type ButtonSize, type ButtonVariant } from './Button'
|
|
18
19
|
export { Card, type CardAttention, type CardFill, type CardHover, type CardProps } from './Card'
|
|
19
20
|
export { Checkbox, type CheckboxProps } from './Checkbox'
|
package/src/looks.ts
CHANGED
|
@@ -45,6 +45,12 @@ export const BARE_INPUT_CLASSES = 'bg-transparent text-text-primary placeholder:
|
|
|
45
45
|
|
|
46
46
|
/** A sidebar row's shape and its words at rest, brightening under the pointer: NavItem, and SectionHeader's `row` (a folder among rows). */
|
|
47
47
|
// 8px corners, a MenuItem row's (Katerina, 2 October).
|
|
48
|
+
/** A quiet icon control: secondary, brightening on a hover fill — IconButton's `muted`. */
|
|
49
|
+
export const MUTED_CONTROL_CLASSES = 'text-text-secondary hover:bg-bg-hover hover:text-text-primary'
|
|
50
|
+
|
|
51
|
+
/** A control that opens a menu: a hover fill, held while its menu is up — PersonTrigger's row, and a ContainerHeader title with a menu. */
|
|
52
|
+
export const MENU_TRIGGER_CLASSES = 'cursor-pointer rounded-md transition-colors hover:bg-bg-hover data-[popup-open]:bg-bg-hover'
|
|
53
|
+
|
|
48
54
|
export const SIDEBAR_ROW_CLASSES = 'rounded-lg transition-colors'
|
|
49
55
|
export const SIDEBAR_ROW_TEXT_CLASSES = 'text-text-secondary hover:text-text-primary'
|
|
50
56
|
|
|
@@ -48,20 +48,20 @@ describe('the registry builds', () => {
|
|
|
48
48
|
expect(registry.entries.map((one) => one.name).sort()).toEqual(values.map((one) => one.name).sort())
|
|
49
49
|
})
|
|
50
50
|
|
|
51
|
-
it('counts
|
|
51
|
+
it('counts 130 names over 69 files: 100 components, 29 helpers and 1 hook (NavTree, NavTreeSection, the composer triggers and UnreadDot joined on 2 October; BlockHandle and EditorBlockHandle (RIC-18) on 2 October, and moveBlock and blockDropCursor, the drop in the gutter, on 5 October)', () => {
|
|
52
52
|
// The reconciliation GATES.md §24 explains. If this changes, that changes.
|
|
53
53
|
// The ten `EDITOR_EXTENSIONS` count as components by their names; they are
|
|
54
54
|
// editor extensions, documented on SelectionToolbar's, BlockNodes' and
|
|
55
55
|
// ComposerTriggers' pages.
|
|
56
56
|
const kinds = registry.entries.reduce<Record<string, number>>((all, one) => ({ ...all, [one.kind]: (all[one.kind] ?? 0) + 1 }), {})
|
|
57
|
-
expect(kinds).toEqual({ component:
|
|
58
|
-
expect(new Set(registry.entries.map((one) => one.sourceFile)).size).toBe(
|
|
57
|
+
expect(kinds).toEqual({ component: 100, helper: 29, hook: 1 })
|
|
58
|
+
expect(new Set(registry.entries.map((one) => one.sourceFile)).size).toBe(69)
|
|
59
59
|
})
|
|
60
60
|
|
|
61
61
|
it('gives every entry a purpose, from its own page or from the comment above it', () => {
|
|
62
62
|
expect(registry.entries.filter((one) => one.purpose.trim() === '')).toEqual([])
|
|
63
|
-
expect(registry.entries.filter((one) => one.purposeFrom === 'page').length).toBe(
|
|
64
|
-
expect(registry.entries.filter((one) => one.purposeFrom === 'comment').length).toBe(
|
|
63
|
+
expect(registry.entries.filter((one) => one.purposeFrom === 'page').length).toBe(65)
|
|
64
|
+
expect(registry.entries.filter((one) => one.purposeFrom === 'comment').length).toBe(65)
|
|
65
65
|
})
|
|
66
66
|
|
|
67
67
|
it('takes a name with a page of its own from the page, and a name without one from the code', () => {
|
package/stories/Choosing.mdx
CHANGED
|
@@ -70,6 +70,8 @@ fork freely, owe nothing back, mention it on the package's ticket.
|
|
|
70
70
|
| Bold, italic and the like over selected text in an editor | **SelectionToolbar**, from `@estiva-app/ui/editor` — it follows the selection by itself |
|
|
71
71
|
| A composer's `@`, `!@`, `[` and `/` — mention a person, reference something, format or insert | **ComposerTriggers** — `PersonMention`, `UrgentPersonMention`, `CaptionedReference`, `ReferenceTrigger` and `SlashCommands` from `@estiva-app/ui/editor`; the app passes its people, its items and its own `/` rows |
|
|
72
72
|
| An editor that saves a block document and must keep its block ids | **BlockNodes** — `BlockId`, `UnknownBlock`, `ReferenceNode` and `AttachmentNode` from `@estiva-app/ui/editor`, beside StarterKit and TableKit |
|
|
73
|
+
| Move, turn, duplicate or delete one block of a document being edited | **EditorBlockHandle**, from `@estiva-app/ui/editor`, with your rows after its own |
|
|
74
|
+
| Offer a link or a comment on one block of a document being read | **BlockHandle** — your rows only, and no Tiptap loaded |
|
|
73
75
|
| Anything with a title, body and footer | **DialogShell** |
|
|
74
76
|
| "Are you sure?" | **ConfirmDialog** |
|
|
75
77
|
| A picture full screen, from a thumbnail | **Lightbox** — an **AttachmentCard** opens one by itself |
|