@estiva-app/ui 0.4.0 → 0.6.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/AppShell.d.ts.map +1 -1
- package/dist/Avatar.d.ts.map +1 -1
- package/dist/AvatarGroup.d.ts +14 -5
- package/dist/AvatarGroup.d.ts.map +1 -1
- package/dist/ChipInput.d.ts.map +1 -1
- package/dist/Divider.d.ts.map +1 -1
- package/dist/IconButton.d.ts +4 -1
- package/dist/IconButton.d.ts.map +1 -1
- package/dist/IdentityMenu.d.ts +6 -1
- package/dist/IdentityMenu.d.ts.map +1 -1
- package/dist/Kbd.d.ts +27 -0
- package/dist/Kbd.d.ts.map +1 -0
- package/dist/Menu.d.ts +115 -11
- package/dist/Menu.d.ts.map +1 -1
- package/dist/SearchInput.d.ts +1 -1
- package/dist/SearchInput.d.ts.map +1 -1
- package/dist/Select.d.ts +2 -31
- package/dist/Select.d.ts.map +1 -1
- package/dist/Tooltip.d.ts +8 -2
- package/dist/Tooltip.d.ts.map +1 -1
- package/dist/fit.d.ts +98 -0
- package/dist/fit.d.ts.map +1 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +441 -232
- package/dist/index.js.map +4 -4
- package/package.json +1 -1
- package/src/AppShell.stories.tsx +2 -2
- package/src/AppShell.tsx +8 -1
- package/src/Avatar.tsx +14 -1
- package/src/AvatarGroup.stories.tsx +5 -0
- package/src/AvatarGroup.tsx +36 -13
- package/src/ChipInput.tsx +15 -3
- package/src/Divider.tsx +5 -1
- package/src/IconButton.tsx +5 -1
- package/src/IdentityMenu.tsx +14 -4
- package/src/Kbd.mdx +77 -0
- package/src/Kbd.stories.tsx +58 -0
- package/src/Kbd.tsx +42 -0
- package/src/Menu.fit.test.ts +90 -0
- package/src/Menu.mdx +8 -1
- package/src/Menu.stories.tsx +26 -3
- package/src/Menu.tsx +310 -34
- package/src/MenuItem.mdx +1 -1
- package/src/MenuItem.stories.tsx +5 -5
- package/src/SearchInput.mdx +2 -2
- package/src/SearchInput.stories.tsx +2 -2
- package/src/SearchInput.tsx +3 -6
- package/src/Select.fit.test.ts +12 -8
- package/src/Select.tsx +8 -32
- package/src/Tooltip.mdx +9 -0
- package/src/Tooltip.stories.tsx +15 -0
- package/src/Tooltip.tsx +19 -5
- package/src/TopBar.mdx +1 -1
- package/src/TopBar.stories.tsx +2 -2
- package/src/fit.ts +94 -0
- package/src/index.ts +3 -1
- package/tailwind-preset.js +9 -1
package/src/Tooltip.mdx
CHANGED
|
@@ -19,6 +19,11 @@ viewport, kept 8px inside its edges.
|
|
|
19
19
|
- The word for an icon-only control — alongside its `aria-label`, never
|
|
20
20
|
instead of it (IconButton wires this for you via its `tooltip` prop).
|
|
21
21
|
- The full text behind a truncation.
|
|
22
|
+
- **The key that does the same thing** — pass `shortcut` and it is drawn as
|
|
23
|
+
the `Kbd` chip after the label. For an icon-only control whose only other
|
|
24
|
+
affordance is a keyboard shortcut, the tooltip is the only place to say so.
|
|
25
|
+
|
|
26
|
+
<Canvas of={TooltipStories.ShortcutComparison} />
|
|
22
27
|
|
|
23
28
|
<Canvas of={TooltipStories.OnADisabledControl} />
|
|
24
29
|
|
|
@@ -45,6 +50,10 @@ import { WithTooltip } from '@estiva-app/ui'
|
|
|
45
50
|
`wrapperClassName="min-w-0 shrink"`.
|
|
46
51
|
- It shows on **hover only** — there is no focus or touch trigger. Don't
|
|
47
52
|
put anything behind it that a keyboard user must reach.
|
|
53
|
+
- `shortcut` renders, it does not format. A modifier is called Cmd on Apple
|
|
54
|
+
platforms and Ctrl elsewhere, and only the caller knows which it is
|
|
55
|
+
looking at — so pass the finished label. `IconButton` forwards its own
|
|
56
|
+
`tooltipShortcut` here.
|
|
48
57
|
|
|
49
58
|
## Props
|
|
50
59
|
|
package/src/Tooltip.stories.tsx
CHANGED
|
@@ -14,6 +14,21 @@ type Story = StoryObj<typeof meta>
|
|
|
14
14
|
/** The static tooltip surface. */
|
|
15
15
|
export const Default: Story = {}
|
|
16
16
|
|
|
17
|
+
/** With a key hint — drawn as the `Kbd` chip after the label. */
|
|
18
|
+
export const WithShortcut: Story = { args: { label: 'Bold', shortcut: 'Cmd+B' } }
|
|
19
|
+
|
|
20
|
+
/** With and without, so the difference is one glance. */
|
|
21
|
+
export const ShortcutComparison: Story = {
|
|
22
|
+
parameters: { controls: { disable: true } },
|
|
23
|
+
render: () => (
|
|
24
|
+
<div className="flex flex-col items-start gap-2">
|
|
25
|
+
<Tooltip label="Comment" />
|
|
26
|
+
<Tooltip label="Bold" shortcut="Cmd+B" />
|
|
27
|
+
<Tooltip label="Heading" shortcut="Ctrl+Alt+1" />
|
|
28
|
+
</div>
|
|
29
|
+
),
|
|
30
|
+
}
|
|
31
|
+
|
|
17
32
|
/** Hover the button — WithTooltip portals the tooltip above the trigger. */
|
|
18
33
|
export const OnHoverTop: Story = {
|
|
19
34
|
parameters: { controls: { disable: true } },
|
package/src/Tooltip.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useCallback, useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react'
|
|
2
2
|
import { createPortal } from 'react-dom'
|
|
3
3
|
import { cn } from './cn'
|
|
4
|
+
import { Kbd } from './Kbd'
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Peek's Tooltip and WithTooltip (2026-08-28), verbatim, in one file.
|
|
@@ -13,19 +14,26 @@ import { cn } from './cn'
|
|
|
13
14
|
*/
|
|
14
15
|
export interface TooltipProps {
|
|
15
16
|
label: string
|
|
17
|
+
/** The key that does the same thing, drawn as the `Kbd` chip after the label.
|
|
18
|
+
* Pass it already formatted for the platform — this renders, it does not
|
|
19
|
+
* decide whether the modifier is a glyph or a word. */
|
|
20
|
+
shortcut?: string
|
|
16
21
|
className?: string
|
|
17
22
|
}
|
|
18
23
|
|
|
19
|
-
export function Tooltip({ label, className }: TooltipProps) {
|
|
24
|
+
export function Tooltip({ label, shortcut, className }: TooltipProps) {
|
|
20
25
|
return (
|
|
21
|
-
<div role="tooltip" className={cn('bg-bg-elevated border border-border-default rounded-lg h-[30px] flex items-center justify-center px-2 shadow-lg', className)}>
|
|
26
|
+
<div role="tooltip" className={cn('bg-bg-elevated border border-border-default rounded-lg h-[30px] flex items-center justify-center gap-1.5 px-2 shadow-lg', className)}>
|
|
22
27
|
<span className="text-caption text-text-primary whitespace-nowrap">{label}</span>
|
|
28
|
+
{shortcut && <Kbd>{shortcut}</Kbd>}
|
|
23
29
|
</div>
|
|
24
30
|
)
|
|
25
31
|
}
|
|
26
32
|
|
|
27
33
|
export interface WithTooltipProps {
|
|
28
34
|
label: string
|
|
35
|
+
/** Passed straight to the surface — see `TooltipProps.shortcut`. */
|
|
36
|
+
shortcut?: string
|
|
29
37
|
placement?: 'top' | 'bottom'
|
|
30
38
|
/** Extra classes on the wrapper — e.g. `min-w-0 shrink` so a truncating label keeps truncating inside it. */
|
|
31
39
|
wrapperClassName?: string
|
|
@@ -35,7 +43,7 @@ export interface WithTooltipProps {
|
|
|
35
43
|
const GAP = 6
|
|
36
44
|
const VIEWPORT_PAD = 8
|
|
37
45
|
|
|
38
|
-
export function WithTooltip({ label, placement = 'top', wrapperClassName, children }: WithTooltipProps) {
|
|
46
|
+
export function WithTooltip({ label, shortcut, placement = 'top', wrapperClassName, children }: WithTooltipProps) {
|
|
39
47
|
const [show, setShow] = useState(false)
|
|
40
48
|
const ref = useRef<HTMLDivElement>(null)
|
|
41
49
|
const tooltipRef = useRef<HTMLDivElement>(null)
|
|
@@ -47,7 +55,13 @@ export function WithTooltip({ label, placement = 'top', wrapperClassName, childr
|
|
|
47
55
|
if (!trigger || !tip) return
|
|
48
56
|
const triggerRect = trigger.getBoundingClientRect()
|
|
49
57
|
const tipRect = tip.getBoundingClientRect()
|
|
50
|
-
|
|
58
|
+
let top = placement === 'bottom' ? triggerRect.bottom + GAP : triggerRect.top - tipRect.height - GAP
|
|
59
|
+
// The vertical axis flips and clamps like the horizontal one always has
|
|
60
|
+
// (2026-09-03): a `top` tooltip on a control near the viewport's top edge
|
|
61
|
+
// was the one floating surface left that could leave the screen.
|
|
62
|
+
if (placement === 'top' && top < VIEWPORT_PAD) top = triggerRect.bottom + GAP
|
|
63
|
+
else if (placement === 'bottom' && top + tipRect.height > window.innerHeight - VIEWPORT_PAD) top = triggerRect.top - tipRect.height - GAP
|
|
64
|
+
top = Math.max(VIEWPORT_PAD, Math.min(top, window.innerHeight - tipRect.height - VIEWPORT_PAD))
|
|
51
65
|
let left = triggerRect.left + triggerRect.width / 2 - tipRect.width / 2
|
|
52
66
|
left = Math.max(VIEWPORT_PAD, Math.min(left, window.innerWidth - tipRect.width - VIEWPORT_PAD))
|
|
53
67
|
setStyle({ position: 'fixed', top, left, zIndex: 9999, pointerEvents: 'none', visibility: 'visible' })
|
|
@@ -63,7 +77,7 @@ export function WithTooltip({ label, placement = 'top', wrapperClassName, childr
|
|
|
63
77
|
{show &&
|
|
64
78
|
createPortal(
|
|
65
79
|
<div ref={tooltipRef} style={style}>
|
|
66
|
-
<Tooltip label={label} />
|
|
80
|
+
<Tooltip label={label} shortcut={shortcut} />
|
|
67
81
|
</div>,
|
|
68
82
|
document.body,
|
|
69
83
|
)}
|
package/src/TopBar.mdx
CHANGED
|
@@ -44,7 +44,7 @@ import { TopBar, IdentityMenu, SearchInput } from '@estiva-app/ui'
|
|
|
44
44
|
|
|
45
45
|
<TopBar
|
|
46
46
|
logo="Estiva"
|
|
47
|
-
search={<SearchInput shortcut="
|
|
47
|
+
search={<SearchInput shortcut="Ctrl+K" className="w-[290px]" />}
|
|
48
48
|
right={<IdentityMenu me={me} signedIn={signedIn} />}
|
|
49
49
|
/>
|
|
50
50
|
```
|
package/src/TopBar.stories.tsx
CHANGED
|
@@ -54,7 +54,7 @@ const Behind = ({ children }: { children: ReactNode }) => (
|
|
|
54
54
|
export const Solid: Story = {
|
|
55
55
|
render: (args) => (
|
|
56
56
|
<Below>
|
|
57
|
-
<TopBar {...args} logo="Estiva" search={<SearchInput shortcut="
|
|
57
|
+
<TopBar {...args} logo="Estiva" search={<SearchInput shortcut="Ctrl+K" className="w-[290px]" />} right={face} />
|
|
58
58
|
</Below>
|
|
59
59
|
),
|
|
60
60
|
}
|
|
@@ -72,7 +72,7 @@ export const SolidNoSearch: Story = {
|
|
|
72
72
|
export const Floating: Story = {
|
|
73
73
|
render: (args) => (
|
|
74
74
|
<Behind>
|
|
75
|
-
<TopBar {...args} variant="floating" menu={menuButton} search={<SearchInput shortcut="
|
|
75
|
+
<TopBar {...args} variant="floating" menu={menuButton} search={<SearchInput shortcut="Ctrl+K" className="w-[290px]" />} right={face} />
|
|
76
76
|
</Behind>
|
|
77
77
|
),
|
|
78
78
|
}
|
package/src/fit.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Viewport geometry for everything that floats — pure, so the decisions are
|
|
3
|
+
* testable without a browser.
|
|
4
|
+
*
|
|
5
|
+
* One module, because the failure it prevents keeps recurring one surface at
|
|
6
|
+
* a time: the files-panel picker was cut off at the right edge (fixed in
|
|
7
|
+
* Select, 2026-09-01), then a reply menu's highlight submenu was cut off the
|
|
8
|
+
* same way and the identity menu vanished under a z-indexed header
|
|
9
|
+
* (2026-09-03). A menu that hand-rolls its own geometry is a menu waiting to
|
|
10
|
+
* be the next screenshot; the shell calls these instead.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const MARGIN = 8
|
|
14
|
+
const GAP = 4
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Where a menu of this size goes, given its anchor and the viewport.
|
|
18
|
+
*
|
|
19
|
+
* Left is clamped inside the viewport with an 8px margin. Height is capped
|
|
20
|
+
* at `cap` — Select's option list keeps the old `max-h-72` (288), a menu
|
|
21
|
+
* panel passes none and uses all the room it opens into, so it scrolls only
|
|
22
|
+
* when the screen truly has no space (the identity panel got Select's cap
|
|
23
|
+
* by accident and grew a scrollbar at full height, 2026-09-03) — but never
|
|
24
|
+
* taller than that room; when the room below the anchor is smaller than
|
|
25
|
+
* both the content and the room above, the menu opens UPWARD (anchored to
|
|
26
|
+
* the trigger's top via `bottom`). The 120px floor keeps a menu usable even
|
|
27
|
+
* in a cramped corner — scrollable beats invisible.
|
|
28
|
+
*/
|
|
29
|
+
export function fitMenu({
|
|
30
|
+
anchor,
|
|
31
|
+
menu,
|
|
32
|
+
viewport,
|
|
33
|
+
cap = Number.POSITIVE_INFINITY,
|
|
34
|
+
}: {
|
|
35
|
+
anchor: { left: number; top: number; bottom: number }
|
|
36
|
+
menu: { width: number; contentHeight: number }
|
|
37
|
+
viewport: { width: number; height: number }
|
|
38
|
+
/** Tallest the menu may stand even with room to spare. Absent: the room is the only limit. */
|
|
39
|
+
cap?: number
|
|
40
|
+
}): { left: number; top?: number; bottom?: number; maxHeight: number } {
|
|
41
|
+
const left = Math.max(MARGIN, Math.min(anchor.left, viewport.width - menu.width - MARGIN))
|
|
42
|
+
const below = viewport.height - anchor.bottom - GAP - MARGIN
|
|
43
|
+
const above = anchor.top - GAP - MARGIN
|
|
44
|
+
const openUp = below < Math.min(menu.contentHeight, cap) && above > below
|
|
45
|
+
const maxHeight = Math.max(Math.min(cap, openUp ? above : below), 120)
|
|
46
|
+
return openUp
|
|
47
|
+
? { left, bottom: viewport.height - anchor.top + GAP, maxHeight }
|
|
48
|
+
: { left, top: anchor.bottom + GAP, maxHeight }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Keep a box a caller has already placed fully on screen.
|
|
53
|
+
*
|
|
54
|
+
* For the menus whose caller measured a rect and chose the corner itself
|
|
55
|
+
* (`position`): the caller's math stays authoritative, but its result can no
|
|
56
|
+
* longer land off screen — it is slid inside the viewport, never flipped,
|
|
57
|
+
* and capped to the viewport's height with the same 120px floor as fitMenu.
|
|
58
|
+
*/
|
|
59
|
+
export function clampBox({
|
|
60
|
+
box,
|
|
61
|
+
viewport,
|
|
62
|
+
}: {
|
|
63
|
+
box: { left: number; top: number; width: number; height: number }
|
|
64
|
+
viewport: { width: number; height: number }
|
|
65
|
+
}): { left: number; top: number; maxHeight: number } {
|
|
66
|
+
const left = Math.max(MARGIN, Math.min(box.left, viewport.width - box.width - MARGIN))
|
|
67
|
+
const maxHeight = Math.max(Math.min(box.height, viewport.height - 2 * MARGIN), 120)
|
|
68
|
+
const top = Math.max(MARGIN, Math.min(box.top, viewport.height - maxHeight - MARGIN))
|
|
69
|
+
return { left, top, maxHeight }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Where a submenu goes, given its trigger row and the viewport.
|
|
74
|
+
*
|
|
75
|
+
* To the RIGHT of the row when it fits, flipped to the LEFT when it does not
|
|
76
|
+
* — the measured flip two menus hand-rolled and one of them broke (the copy
|
|
77
|
+
* dropped the ref its measurement read, so it measured nothing and always
|
|
78
|
+
* opened rightward, off the screen). Top-aligned with the row, slid up when
|
|
79
|
+
* the tail would run past the bottom edge.
|
|
80
|
+
*/
|
|
81
|
+
export function fitSubmenu({
|
|
82
|
+
row,
|
|
83
|
+
panel,
|
|
84
|
+
viewport,
|
|
85
|
+
}: {
|
|
86
|
+
row: { left: number; right: number; top: number }
|
|
87
|
+
panel: { width: number; height: number }
|
|
88
|
+
viewport: { width: number; height: number }
|
|
89
|
+
}): { left: number; top: number } {
|
|
90
|
+
const fitsRight = row.right + GAP + panel.width <= viewport.width - MARGIN
|
|
91
|
+
const left = Math.max(MARGIN, fitsRight ? row.right + GAP : row.left - GAP - panel.width)
|
|
92
|
+
const top = Math.max(MARGIN, Math.min(row.top, viewport.height - panel.height - MARGIN))
|
|
93
|
+
return { left, top }
|
|
94
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -18,6 +18,7 @@ export { Checkbox, type CheckboxProps } from './Checkbox'
|
|
|
18
18
|
export { Chip, type ChipProps, type ChipType } from './Chip'
|
|
19
19
|
export { ChipInput, InputChip, type ChipInputOption, type ChipInputProps, type InputChipProps } from './ChipInput'
|
|
20
20
|
export { IconButton, type IconButtonProps, type IconButtonVariant } from './IconButton'
|
|
21
|
+
export { Kbd, type KbdProps } from './Kbd'
|
|
21
22
|
export { IdentityMenu, IdentityPanel, type Identity, type IdentityMenuProps, type IdentityPanelProps } from './IdentityMenu'
|
|
22
23
|
export { Tooltip, WithTooltip, type TooltipProps, type WithTooltipProps } from './Tooltip'
|
|
23
24
|
export { Field, useFieldControlId, type FieldProps } from './Field'
|
|
@@ -31,7 +32,8 @@ export { EditableText, type EditableTextProps } from './EditableText'
|
|
|
31
32
|
export { EmptyState, type EmptyStateProps } from './EmptyState'
|
|
32
33
|
export { SkeletonBar, SkeletonList, SkeletonRow } from './Skeleton'
|
|
33
34
|
export { Breadcrumb, type BreadcrumbProps, type Crumb } from './Breadcrumb'
|
|
34
|
-
export { EnterHint, Menu, MenuItem, MenuRow, MenuSection, type MenuItemProps, type MenuProps } from './Menu'
|
|
35
|
+
export { EnterHint, Menu, MenuItem, MenuPanel, MenuRow, MenuSection, MenuSub, type MenuItemProps, type MenuPanelProps, type MenuProps, type MenuSubProps } from './Menu'
|
|
36
|
+
export { clampBox, fitMenu, fitSubmenu } from './fit'
|
|
35
37
|
export { NavItem, type NavItemProps } from './NavItem'
|
|
36
38
|
export { Person, type PersonProps } from './Person'
|
|
37
39
|
export { Rail, type RailProps } from './Rail'
|
package/tailwind-preset.js
CHANGED
|
@@ -63,7 +63,15 @@ export const estivaContent = [own('./dist/*.js'), own('./src/*.{ts,tsx}')]
|
|
|
63
63
|
export default {
|
|
64
64
|
|
|
65
65
|
darkMode: 'class',
|
|
66
|
-
|
|
66
|
+
// `signal` is Peek's shipped theme (a class); `ship` is Ship's (an attribute,
|
|
67
|
+
// see tokens.css). Both exist so a treatment can be given to the two apps
|
|
68
|
+
// without changing the plain light/dark themes the docs render in.
|
|
69
|
+
plugins: [
|
|
70
|
+
plugin(({ addVariant }) => {
|
|
71
|
+
addVariant('signal', '.signal &')
|
|
72
|
+
addVariant('ship', "[data-theme='ship'] &")
|
|
73
|
+
}),
|
|
74
|
+
],
|
|
67
75
|
theme: {
|
|
68
76
|
extend: {
|
|
69
77
|
fontFamily: {
|