@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.
Files changed (58) hide show
  1. package/dist/AppShell.d.ts.map +1 -1
  2. package/dist/Avatar.d.ts.map +1 -1
  3. package/dist/AvatarGroup.d.ts +14 -5
  4. package/dist/AvatarGroup.d.ts.map +1 -1
  5. package/dist/ChipInput.d.ts.map +1 -1
  6. package/dist/Divider.d.ts.map +1 -1
  7. package/dist/IconButton.d.ts +4 -1
  8. package/dist/IconButton.d.ts.map +1 -1
  9. package/dist/IdentityMenu.d.ts +6 -1
  10. package/dist/IdentityMenu.d.ts.map +1 -1
  11. package/dist/Kbd.d.ts +27 -0
  12. package/dist/Kbd.d.ts.map +1 -0
  13. package/dist/Menu.d.ts +115 -11
  14. package/dist/Menu.d.ts.map +1 -1
  15. package/dist/SearchInput.d.ts +1 -1
  16. package/dist/SearchInput.d.ts.map +1 -1
  17. package/dist/Select.d.ts +2 -31
  18. package/dist/Select.d.ts.map +1 -1
  19. package/dist/Tooltip.d.ts +8 -2
  20. package/dist/Tooltip.d.ts.map +1 -1
  21. package/dist/fit.d.ts +98 -0
  22. package/dist/fit.d.ts.map +1 -0
  23. package/dist/index.d.ts +3 -1
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +441 -232
  26. package/dist/index.js.map +4 -4
  27. package/package.json +1 -1
  28. package/src/AppShell.stories.tsx +2 -2
  29. package/src/AppShell.tsx +8 -1
  30. package/src/Avatar.tsx +14 -1
  31. package/src/AvatarGroup.stories.tsx +5 -0
  32. package/src/AvatarGroup.tsx +36 -13
  33. package/src/ChipInput.tsx +15 -3
  34. package/src/Divider.tsx +5 -1
  35. package/src/IconButton.tsx +5 -1
  36. package/src/IdentityMenu.tsx +14 -4
  37. package/src/Kbd.mdx +77 -0
  38. package/src/Kbd.stories.tsx +58 -0
  39. package/src/Kbd.tsx +42 -0
  40. package/src/Menu.fit.test.ts +90 -0
  41. package/src/Menu.mdx +8 -1
  42. package/src/Menu.stories.tsx +26 -3
  43. package/src/Menu.tsx +310 -34
  44. package/src/MenuItem.mdx +1 -1
  45. package/src/MenuItem.stories.tsx +5 -5
  46. package/src/SearchInput.mdx +2 -2
  47. package/src/SearchInput.stories.tsx +2 -2
  48. package/src/SearchInput.tsx +3 -6
  49. package/src/Select.fit.test.ts +12 -8
  50. package/src/Select.tsx +8 -32
  51. package/src/Tooltip.mdx +9 -0
  52. package/src/Tooltip.stories.tsx +15 -0
  53. package/src/Tooltip.tsx +19 -5
  54. package/src/TopBar.mdx +1 -1
  55. package/src/TopBar.stories.tsx +2 -2
  56. package/src/fit.ts +94 -0
  57. package/src/index.ts +3 -1
  58. 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
 
@@ -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
- const top = placement === 'bottom' ? triggerRect.bottom + GAP : triggerRect.top - tipRect.height - GAP
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="K" className="w-[290px]" />}
47
+ search={<SearchInput shortcut="Ctrl+K" className="w-[290px]" />}
48
48
  right={<IdentityMenu me={me} signedIn={signedIn} />}
49
49
  />
50
50
  ```
@@ -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="K" className="w-[290px]" />} right={face} />
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="K" className="w-[290px]" />} right={face} />
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'
@@ -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
- plugins: [plugin(({ addVariant }) => addVariant('signal', '.signal &'))],
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: {