@liberation-data/desk 0.1.0 → 0.2.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 (57) hide show
  1. package/README.md +92 -1
  2. package/dist/core/desk.d.ts.map +1 -1
  3. package/dist/core/desk.js +19 -2
  4. package/dist/core/desk.js.map +1 -1
  5. package/dist/core/index.d.ts +2 -0
  6. package/dist/core/index.d.ts.map +1 -1
  7. package/dist/core/index.js +1 -0
  8. package/dist/core/index.js.map +1 -1
  9. package/dist/core/layouts.d.ts +26 -0
  10. package/dist/core/layouts.d.ts.map +1 -0
  11. package/dist/core/layouts.js +70 -0
  12. package/dist/core/layouts.js.map +1 -0
  13. package/dist/core/types.d.ts +3 -0
  14. package/dist/core/types.d.ts.map +1 -1
  15. package/dist/desk.css +146 -1
  16. package/dist/react/Desktop.d.ts +7 -1
  17. package/dist/react/Desktop.d.ts.map +1 -1
  18. package/dist/react/Desktop.js +4 -4
  19. package/dist/react/Desktop.js.map +1 -1
  20. package/dist/react/Dock.d.ts +16 -1
  21. package/dist/react/Dock.d.ts.map +1 -1
  22. package/dist/react/Dock.js +44 -6
  23. package/dist/react/Dock.js.map +1 -1
  24. package/dist/react/MenuBar.d.ts +16 -0
  25. package/dist/react/MenuBar.d.ts.map +1 -1
  26. package/dist/react/MenuBar.js +36 -27
  27. package/dist/react/MenuBar.js.map +1 -1
  28. package/dist/react/context.d.ts +2 -0
  29. package/dist/react/context.d.ts.map +1 -1
  30. package/dist/react/context.js +2 -0
  31. package/dist/react/context.js.map +1 -1
  32. package/dist/react/contextMenu.d.ts +22 -0
  33. package/dist/react/contextMenu.d.ts.map +1 -0
  34. package/dist/react/contextMenu.js +146 -0
  35. package/dist/react/contextMenu.js.map +1 -0
  36. package/dist/react/iconView.d.ts +34 -0
  37. package/dist/react/iconView.d.ts.map +1 -0
  38. package/dist/react/iconView.js +112 -0
  39. package/dist/react/iconView.js.map +1 -0
  40. package/dist/react/index.d.ts +6 -2
  41. package/dist/react/index.d.ts.map +1 -1
  42. package/dist/react/index.js +3 -1
  43. package/dist/react/index.js.map +1 -1
  44. package/llms.txt +22 -4
  45. package/package.json +1 -1
  46. package/src/core/desk.ts +17 -2
  47. package/src/core/index.ts +2 -0
  48. package/src/core/layouts.ts +88 -0
  49. package/src/core/types.ts +4 -0
  50. package/src/desk.css +146 -1
  51. package/src/react/Desktop.tsx +11 -2
  52. package/src/react/Dock.tsx +74 -5
  53. package/src/react/MenuBar.tsx +43 -30
  54. package/src/react/context.tsx +3 -0
  55. package/src/react/contextMenu.tsx +198 -0
  56. package/src/react/iconView.tsx +206 -0
  57. package/src/react/index.ts +6 -2
package/src/desk.css CHANGED
@@ -216,6 +216,23 @@
216
216
  color: var(--desk-muted);
217
217
  }
218
218
 
219
+ .desk-window-note {
220
+ margin: 0 0 0 auto;
221
+ min-width: 0;
222
+ /* The title is what the window IS; the note is what it is looking at. In a narrow window the note
223
+ gives up its room first, and is cut before the title is. */
224
+ flex: 0 999 auto;
225
+ overflow: hidden;
226
+ text-overflow: ellipsis;
227
+ white-space: nowrap;
228
+ font-size: 11.5px;
229
+ color: var(--desk-muted);
230
+ }
231
+
232
+ .desk-window-note + .desk-window-actions {
233
+ margin-left: var(--desk-space-1);
234
+ }
235
+
219
236
  .desk-window-actions {
220
237
  margin-left: auto;
221
238
  display: flex;
@@ -372,7 +389,8 @@
372
389
  transition: transform .18s var(--desk-ease), margin .18s var(--desk-ease);
373
390
  }
374
391
 
375
- .desk-dock-item:disabled {
392
+ .desk-dock-item:disabled,
393
+ .desk-dock-item[aria-disabled="true"] {
376
394
  opacity: .45;
377
395
  cursor: not-allowed;
378
396
  }
@@ -658,6 +676,13 @@
658
676
  height: 15px;
659
677
  }
660
678
 
679
+ /* A context menu is the same menu, opened where the pointer is rather than under a title. */
680
+ .desk-context-menu {
681
+ position: fixed;
682
+ z-index: 1300;
683
+ outline: none;
684
+ }
685
+
661
686
  .desk-menubar-slot[data-align="end"] .desk-menu {
662
687
  left: auto;
663
688
  right: 0;
@@ -2548,3 +2573,123 @@
2548
2573
  border: 0;
2549
2574
  background: var(--desk-surface);
2550
2575
  }
2576
+
2577
+ /* Icon view: a Finder's icons, in rows that follow the window's width. */
2578
+ .desk-icon-view {
2579
+ list-style: none;
2580
+ margin: 0;
2581
+ padding: 12px;
2582
+ display: grid;
2583
+ grid-template-columns: repeat(auto-fill, minmax(104px, 1fr));
2584
+ align-content: start;
2585
+ gap: 8px;
2586
+ }
2587
+
2588
+ .desk-icon-cell {
2589
+ display: grid;
2590
+ justify-items: center;
2591
+ align-content: start;
2592
+ gap: 4px;
2593
+ padding: 10px 6px;
2594
+ border-radius: 10px;
2595
+ text-align: center;
2596
+ cursor: default;
2597
+ user-select: none;
2598
+ outline: none;
2599
+ }
2600
+
2601
+ .desk-icon-cell[aria-disabled="true"] {
2602
+ opacity: .45;
2603
+ }
2604
+
2605
+ .desk-icon-cell:focus-visible {
2606
+ box-shadow: 0 0 0 2px var(--desk-accent);
2607
+ }
2608
+
2609
+ .desk-icon-glyph {
2610
+ position: relative;
2611
+ display: grid;
2612
+ place-items: center;
2613
+ width: 56px;
2614
+ height: 56px;
2615
+ border-radius: 14px;
2616
+ border: 1px solid var(--desk-rule);
2617
+ background: var(--desk-dock-icon);
2618
+ color: var(--desk-ink);
2619
+ }
2620
+
2621
+ .desk-icon-glyph > svg,
2622
+ .desk-icon-glyph > img {
2623
+ width: 55%;
2624
+ height: 55%;
2625
+ object-fit: contain;
2626
+ }
2627
+
2628
+ .desk-icon-badge {
2629
+ position: absolute;
2630
+ top: -5px;
2631
+ right: -5px;
2632
+ min-width: 18px;
2633
+ padding: 0 5px;
2634
+ border-radius: 9px;
2635
+ background: var(--desk-accent);
2636
+ color: #fff;
2637
+ font-size: 11px;
2638
+ line-height: 18px;
2639
+ }
2640
+
2641
+ .desk-icon-label {
2642
+ max-width: 100%;
2643
+ padding: 1px 6px;
2644
+ border-radius: 5px;
2645
+ font-size: 12.5px;
2646
+ line-height: 1.3;
2647
+ overflow-wrap: anywhere;
2648
+ display: -webkit-box;
2649
+ -webkit-line-clamp: 2;
2650
+ -webkit-box-orient: vertical;
2651
+ overflow: hidden;
2652
+ }
2653
+
2654
+ .desk-icon-subtitle {
2655
+ max-width: 100%;
2656
+ font-size: 11px;
2657
+ color: var(--desk-muted);
2658
+ overflow: hidden;
2659
+ text-overflow: ellipsis;
2660
+ white-space: nowrap;
2661
+ }
2662
+
2663
+ .desk-icon-cell[aria-selected="true"] .desk-icon-glyph {
2664
+ background: color-mix(in srgb, var(--desk-accent) 16%, transparent);
2665
+ }
2666
+
2667
+ .desk-icon-cell[aria-selected="true"] .desk-icon-label {
2668
+ background: var(--desk-accent);
2669
+ color: #fff;
2670
+ }
2671
+
2672
+ .desk-icon-view-empty {
2673
+ display: grid;
2674
+ place-items: center;
2675
+ padding: 32px 16px;
2676
+ color: var(--desk-muted);
2677
+ text-align: center;
2678
+ }
2679
+
2680
+ .desk-icon-drag {
2681
+ display: grid;
2682
+ place-items: center;
2683
+ width: 40px;
2684
+ height: 40px;
2685
+ }
2686
+
2687
+ /* Something carried to the dock: the dock lights up, and a kept item shows where it would go in front. */
2688
+ .desk-dock-item[data-desk-drop][data-ready] {
2689
+ outline: none;
2690
+ background: var(--desk-dock-icon);
2691
+ }
2692
+
2693
+ .desk-dock-item[data-desk-drop][data-over] {
2694
+ box-shadow: -6px 0 0 -2px var(--desk-accent);
2695
+ }
@@ -13,6 +13,12 @@ export type DeskLayout = 'desktop' | 'fullscreen'
13
13
  export interface DesktopProps {
14
14
  readonly renderWindow: (id: WindowId) => ReactNode
15
15
  readonly title: (id: WindowId) => ReactNode
16
+ /**
17
+ * A quiet line on the trailing side of the title bar, saying what this window is looking at: the
18
+ * request behind it, the file it is editing, how many rows it found. Text, not a control — it
19
+ * gives way to the actions and disappears before the title does when the window is narrow.
20
+ */
21
+ readonly note?: (id: WindowId) => ReactNode
16
22
  /** Window-wide controls on the trailing side of the title bar. One or two; more belongs in a toolbar. */
17
23
  readonly actions?: (id: WindowId) => ReactNode
18
24
  /** Shown when no window is open. */
@@ -70,7 +76,7 @@ const halfFrame = (stage: HTMLElement, side: 'start' | 'end'): Frame => {
70
76
  return { x: side === 'start' ? area.x : area.x + width + area.gap, y: area.y, width, height: area.height }
71
77
  }
72
78
 
73
- export function Desktop({ renderWindow, title, actions, empty, loading, failed, layout = 'auto', className }: DesktopProps) {
79
+ export function Desktop({ renderWindow, title, note, actions, empty, loading, failed, layout = 'auto', className }: DesktopProps) {
74
80
  const desk = useDesk()
75
81
  const state = useDeskState()
76
82
  const stage = useRef<HTMLDivElement>(null)
@@ -150,6 +156,7 @@ export function Desktop({ renderWindow, title, actions, empty, loading, failed,
150
156
  // One window at a time: the rest stay mounted, keeping their state, and simply wait offstage.
151
157
  hidden={mode === 'fullscreen' && window.id !== focused}
152
158
  title={title(window.id)}
159
+ note={note?.(window.id)}
153
160
  actions={actions?.(window.id)}
154
161
  >
155
162
  {/* Memoised on the id and the render function: moving or focusing a window
@@ -184,6 +191,7 @@ interface WindowViewProps {
184
191
  readonly depth: number
185
192
  readonly focused: boolean
186
193
  readonly title: ReactNode
194
+ readonly note?: ReactNode
187
195
  readonly actions?: ReactNode
188
196
  readonly children: ReactNode
189
197
  }
@@ -210,7 +218,7 @@ function reshape(gesture: Gesture, origin: Frame, dx: number, dy: number): Frame
210
218
  }
211
219
  }
212
220
 
213
- function WindowView({ window, layout, hidden, depth, focused, title, actions, children }: WindowViewProps) {
221
+ function WindowView({ window, layout, hidden, depth, focused, title, note, actions, children }: WindowViewProps) {
214
222
  const desk = useDesk()
215
223
  // While dragging, the frame lives here and commits once on release, so a drag
216
224
  // re-renders one window rather than notifying every subscriber per pixel.
@@ -331,6 +339,7 @@ function WindowView({ window, layout, hidden, depth, focused, title, actions, ch
331
339
  <h2 id={titleId} className="desk-title">
332
340
  {title}
333
341
  </h2>
342
+ {note != null && note !== false && <p className="desk-window-note">{note}</p>}
334
343
  {actions && <div className="desk-window-actions">{actions}</div>}
335
344
  </header>
336
345
  <div className="desk-body">{children}</div>
@@ -3,6 +3,11 @@ import type { KeyboardEvent, ReactNode } from 'react'
3
3
  import { focusedId, instancesOf, windowType } from '../core/desk.js'
4
4
  import type { DeskState, WindowId } from '../core/types.js'
5
5
  import { useDesk, useDeskState } from './context.js'
6
+ import { useContextMenu } from './contextMenu.js'
7
+ import { useDragSource, useDropTarget } from './dnd.js'
8
+ import type { Accepts, Drag } from './dnd.js'
9
+ import { accepted } from './dragContext.js'
10
+ import type { MenuItem } from './MenuBar.js'
6
11
 
7
12
  export interface DockItem {
8
13
  readonly id: string
@@ -16,6 +21,26 @@ export interface DockItem {
16
21
  /** Replaces the default of opening the item's window. */
17
22
  readonly onSelect?: () => void
18
23
  readonly disabled?: boolean
24
+ /** What a right-click (or the Menu key) offers for this item: Open, Remove from Dock. */
25
+ readonly contextMenu?: () => readonly MenuItem[]
26
+ /** Can be dragged along the dock to a new place, when the dock has `pins.onMove`. */
27
+ readonly movable?: boolean
28
+ }
29
+
30
+ /** The drag type of an item carried along the dock; its payload is the item's id. */
31
+ export const DOCK_ITEM = 'desk.dock-item'
32
+
33
+ /*
34
+ * Keeping things in the dock. The dock does not store what is kept: the app does, where it keeps its
35
+ * other preferences, and passes the kept items back as entries. The dock reports what the person did:
36
+ * dropped something on it, or carried a kept item to a new place. `before` is the id of the item it
37
+ * landed on, which it goes in front of, or null for the end.
38
+ */
39
+ export interface DockPins {
40
+ /** The drag types that can be kept by dropping them on the dock. */
41
+ readonly accepts: Accepts
42
+ readonly onPin: (drag: Drag, before: string | null) => void
43
+ readonly onMove?: (id: string, before: string | null) => void
19
44
  }
20
45
 
21
46
  export interface DockStack {
@@ -41,6 +66,7 @@ export interface DockProps {
41
66
  /** `overlay` floats the dock over the bottom of its positioned parent; `inline` leaves layout to you. */
42
67
  readonly placement?: 'overlay' | 'inline'
43
68
  readonly className?: string
69
+ readonly pins?: DockPins
44
70
  }
45
71
 
46
72
  const windowOf = (item: DockItem) => item.window ?? item.id
@@ -75,9 +101,14 @@ function roveFocus(event: KeyboardEvent<HTMLElement>, keys: { next: string[]; pr
75
101
  target.focus()
76
102
  }
77
103
 
78
- export function Dock({ entries, label = 'Dock', placement = 'overlay', className }: DockProps) {
104
+ export function Dock({ entries, label = 'Dock', placement = 'overlay', className, pins }: DockProps) {
79
105
  const [openStack, setOpenStack] = useState<string | null>(null)
80
106
  const firstFocusable = entries.find(e => e.type !== 'separator')?.id
107
+ const { dropProps } = useDropTarget({
108
+ accepts: pinAccepts(pins),
109
+ onDrop: drag => deliver(pins, drag, null),
110
+ disabled: !pins,
111
+ })
81
112
 
82
113
  return (
83
114
  <div
@@ -86,6 +117,7 @@ export function Dock({ entries, label = 'Dock', placement = 'overlay', className
86
117
  aria-orientation="horizontal"
87
118
  className={['desk-dock', className].filter(Boolean).join(' ')}
88
119
  data-placement={placement}
120
+ {...dropProps}
89
121
  onKeyDown={event => {
90
122
  if ((event.target as HTMLElement).closest('.desk-stack')) return
91
123
  roveFocus(event, { next: ['ArrowRight'], previous: ['ArrowLeft'] })
@@ -94,7 +126,7 @@ export function Dock({ entries, label = 'Dock', placement = 'overlay', className
94
126
  {entries.map(entry => {
95
127
  if (entry.type === 'separator') return <span key={entry.id} className="desk-dock-separator" role="separator" />
96
128
  if (entry.type === 'item')
97
- return <DockButton key={entry.id} item={entry} tabIndex={entry.id === firstFocusable ? 0 : -1} />
129
+ return <DockButton key={entry.id} item={entry} tabIndex={entry.id === firstFocusable ? 0 : -1} pins={pins} />
98
130
  return (
99
131
  <StackButton
100
132
  key={entry.id}
@@ -113,10 +145,35 @@ function Badge({ children }: { readonly children: ReactNode }) {
113
145
  return <span className="desk-dock-badge">{children}</span>
114
146
  }
115
147
 
116
- function DockButton({ item, tabIndex }: { readonly item: DockItem; readonly tabIndex: number }) {
148
+ // A kept item can be carried along the dock as well as whatever the app lets it keep.
149
+ const pinAccepts = (pins: DockPins | undefined): Accepts => type =>
150
+ Boolean(pins) && ((type === DOCK_ITEM && Boolean(pins?.onMove)) || (type !== DOCK_ITEM && accepted(pins!.accepts, type)))
151
+
152
+ function deliver(pins: DockPins | undefined, drag: Drag, before: string | null) {
153
+ if (!pins) return
154
+ if (drag.type === DOCK_ITEM) {
155
+ const id = drag.payload as string
156
+ if (id !== before) pins.onMove?.(id, before)
157
+ } else pins.onPin(drag, before)
158
+ }
159
+
160
+ interface DockButtonProps {
161
+ readonly item: DockItem
162
+ readonly tabIndex: number
163
+ readonly pins: DockPins | undefined
164
+ }
165
+
166
+ function DockButton({ item, tabIndex, pins }: DockButtonProps) {
117
167
  const desk = useDesk()
118
168
  const { running, focused } = statusOf(useDeskState(), [item])
169
+ const context = useContextMenu({ label: item.label, items: item.contextMenu ?? (() => []), disabled: !item.contextMenu })
170
+ // Only kept items take drops, so something carried lands among them and never splits the fixed ones.
171
+ const droppable = Boolean(pins && item.movable)
172
+ const { dropProps } = useDropTarget({ accepts: pinAccepts(pins), onDrop: drag => deliver(pins, drag, item.id), disabled: !droppable })
173
+ const source = useDragSource<string>({ type: DOCK_ITEM, disabled: !(droppable && pins?.onMove) })
174
+ const drag = droppable && pins?.onMove ? source.dragProps(item.id, <span className="desk-dock-icon">{item.icon}</span>) : {}
119
175
  return (
176
+ <>
120
177
  <button
121
178
  type="button"
122
179
  data-rove
@@ -125,8 +182,18 @@ function DockButton({ item, tabIndex }: { readonly item: DockItem; readonly tabI
125
182
  aria-label={item.label}
126
183
  data-running={running || undefined}
127
184
  data-focused={focused || undefined}
128
- disabled={item.disabled}
129
- onClick={() => (item.onSelect ? item.onSelect() : desk.open(windowOf(item)))}
185
+ // An item with a menu stays reachable while it cannot open, so it can still be removed.
186
+ disabled={item.disabled && !item.contextMenu}
187
+ aria-disabled={(item.disabled && item.contextMenu && true) || undefined}
188
+ {...dropProps}
189
+ {...drag}
190
+ onClick={() => {
191
+ if (item.disabled) return
192
+ if (item.onSelect) item.onSelect()
193
+ else desk.open(windowOf(item))
194
+ }}
195
+ onContextMenu={context.target.onContextMenu}
196
+ onKeyDown={context.target.onKeyDown}
130
197
  >
131
198
  <span className="desk-dock-icon" aria-hidden="true">
132
199
  {item.icon}
@@ -136,6 +203,8 @@ function DockButton({ item, tabIndex }: { readonly item: DockItem; readonly tabI
136
203
  {item.label}
137
204
  </span>
138
205
  </button>
206
+ {context.menu}
207
+ </>
139
208
  )
140
209
  }
141
210
 
@@ -4,6 +4,7 @@ import { canPerform, perform } from '../core/commands.js'
4
4
  import type { CommandId } from '../core/commands.js'
5
5
  import { focusedId } from '../core/desk.js'
6
6
  import { bindShortcuts, formatShortcut, isApplePlatform } from '../core/shortcuts.js'
7
+ import type { Desk } from '../core/desk.js'
7
8
  import type { DeskState, WindowId } from '../core/types.js'
8
9
  import { useDesk } from './context.js'
9
10
 
@@ -79,11 +80,13 @@ export interface MenuBarProps {
79
80
  readonly className?: string
80
81
  }
81
82
 
82
- interface Resolved {
83
+ /** A menu item as it stands when the menu opens: whether it can be chosen, and whether it is checked. */
84
+ export interface ResolvedMenuItem {
83
85
  readonly item: MenuItem
84
86
  readonly enabled: boolean
85
87
  readonly checked: boolean | undefined
86
88
  }
89
+ type Resolved = ResolvedMenuItem
87
90
 
88
91
  interface OpenMenu {
89
92
  readonly id: string
@@ -93,7 +96,39 @@ interface OpenMenu {
93
96
  }
94
97
 
95
98
  const read = (flag: Flag | undefined) => (typeof flag === 'function' ? flag() : flag)
96
- const selectable = (r: Resolved) => r.enabled && (r.item.type === 'command' || r.item.type === 'action')
99
+
100
+ /** Can this item be moved to and chosen: an enabled command or action, not a separator or header. */
101
+ export const selectable = (r: Resolved) => r.enabled && (r.item.type === 'command' || r.item.type === 'action')
102
+
103
+ /** Items as they stand now: commands ask the responder chain whether anything would handle them. */
104
+ export const resolveMenuItems = (desk: Desk | null, items: readonly MenuItem[]): Resolved[] =>
105
+ items.map(item => ({
106
+ item,
107
+ enabled:
108
+ item.type === 'command' ? canPerform(desk, item.command)
109
+ : item.type === 'action' ? !read(item.disabled)
110
+ : false,
111
+ checked: item.type === 'command' || item.type === 'action' ? read(item.checked) : undefined,
112
+ }))
113
+
114
+ /** The next selectable item from `from`, wrapping; -1 when there is none. */
115
+ export const stepMenu = (items: readonly Resolved[], from: number, delta: 1 | -1) => {
116
+ for (let i = 1; i <= items.length; i++) {
117
+ const index = (from + delta * i + items.length * 2) % items.length
118
+ if (items[index] && selectable(items[index])) return index
119
+ }
120
+ return -1
121
+ }
122
+
123
+ /** The next selectable item whose label starts with `letter`, after `from`; -1 when there is none. */
124
+ export const typeAhead = (items: readonly Resolved[], from: number, letter: string) => {
125
+ for (let i = 1; i <= items.length; i++) {
126
+ const index = (from + i) % items.length
127
+ const r = items[index]
128
+ if (r && selectable(r) && 'label' in r.item && r.item.label.toLowerCase().startsWith(letter.toLowerCase())) return index
129
+ }
130
+ return -1
131
+ }
97
132
 
98
133
  export function MenuBar({ menus, status = [], leading, trailing, label = 'Menu bar', shortcuts = true, className }: MenuBarProps) {
99
134
  const desk = useDesk()
@@ -107,23 +142,8 @@ export function MenuBar({ menus, status = [], leading, trailing, label = 'Menu b
107
142
  const baseId = useId()
108
143
  const apple = useMemo(isApplePlatform, [])
109
144
 
110
- const resolve = (menu: Menu): Resolved[] =>
111
- (typeof menu.items === 'function' ? menu.items() : menu.items).map(item => ({
112
- item,
113
- enabled:
114
- item.type === 'command' ? canPerform(desk, item.command)
115
- : item.type === 'action' ? !read(item.disabled)
116
- : false,
117
- checked: item.type === 'command' || item.type === 'action' ? read(item.checked) : undefined,
118
- }))
119
-
120
- const step = (items: readonly Resolved[], from: number, delta: 1 | -1) => {
121
- for (let i = 1; i <= items.length; i++) {
122
- const index = (from + delta * i + items.length * 2) % items.length
123
- if (items[index] && selectable(items[index])) return index
124
- }
125
- return -1
126
- }
145
+ const resolve = (menu: Menu): Resolved[] => resolveMenuItems(desk, typeof menu.items === 'function' ? menu.items() : menu.items)
146
+ const step = stepMenu
127
147
 
128
148
  const openMenu = (index: number, via: OpenMenu['via'], active: 'first' | 'last' | 'none' = 'first') => {
129
149
  const menu = all[(index + all.length) % all.length]
@@ -178,17 +198,10 @@ export function MenuBar({ menus, status = [], leading, trailing, label = 'Menu b
178
198
  case 'Tab': close(false); return false
179
199
  default: {
180
200
  if (event.key.length !== 1 || event.metaKey || event.ctrlKey || event.altKey) return false
181
- const letter = event.key.toLowerCase()
182
- const start = open.active
183
- for (let i = 1; i <= open.items.length; i++) {
184
- const index = (start + i) % open.items.length
185
- const r = open.items[index]
186
- if (r && selectable(r) && 'label' in r.item && r.item.label.toLowerCase().startsWith(letter)) {
187
- setOpen({ ...open, active: index })
188
- return true
189
- }
190
- }
191
- return false
201
+ const index = typeAhead(open.items, open.active, event.key)
202
+ if (index < 0) return false
203
+ setOpen({ ...open, active: index })
204
+ return true
192
205
  }
193
206
  }
194
207
  })()
@@ -32,6 +32,9 @@ export function DeskProvider({ desk, options, children }: DeskProviderProps) {
32
32
  )
33
33
  }
34
34
 
35
+ /** The desk above, or null outside one: for parts that work on a desk and off one, like a context menu. */
36
+ export const useOptionalDesk = (): Desk | null => useContext(DeskContext)
37
+
35
38
  export function useDesk(): Desk {
36
39
  const desk = useContext(DeskContext)
37
40
  if (!desk) throw new Error('useDesk must be used inside a <DeskProvider>')
@@ -0,0 +1,198 @@
1
+ import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from 'react'
2
+ import type { KeyboardEvent as ReactKeyboardEvent, MouseEvent as ReactMouseEvent, ReactNode } from 'react'
3
+ import { createPortal } from 'react-dom'
4
+ import { perform } from '../core/commands.js'
5
+ import { formatShortcut, isApplePlatform } from '../core/shortcuts.js'
6
+ import { useOptionalDesk } from './context.js'
7
+ import { resolveMenuItems, selectable, stepMenu, typeAhead } from './MenuBar.js'
8
+ import type { MenuItem, ResolvedMenuItem } from './MenuBar.js'
9
+
10
+ /*
11
+ * The menu a right-click opens: the things you can do to this one item, where the pointer is.
12
+ *
13
+ * The keyboard reaches it too — the Menu key, or Shift+F10 — and it opens at the item instead.
14
+ * It uses the menu bar's items, so a command in a context menu asks the responder chain whether it
15
+ * is enabled exactly as a menu-bar command does, and runs from the item that was right-clicked.
16
+ */
17
+
18
+ export interface ContextMenuOptions {
19
+ /** The items, built as the menu opens: what is possible can depend on the item right now. */
20
+ readonly items: () => readonly MenuItem[]
21
+ /** Names the menu for assistive technology: "Actions for Ride Log". */
22
+ readonly label: string
23
+ readonly disabled?: boolean
24
+ }
25
+
26
+ export interface ContextMenuTargetProps {
27
+ readonly onContextMenu: (event: ReactMouseEvent<HTMLElement>) => void
28
+ readonly onKeyDown: (event: ReactKeyboardEvent<HTMLElement>) => void
29
+ }
30
+
31
+ export interface ContextMenuResult {
32
+ /** Spread on the thing that has the menu. Chain your own `onKeyDown` through `target.onKeyDown`. */
33
+ readonly target: ContextMenuTargetProps
34
+ /** Render it anywhere: it draws into the document body. */
35
+ readonly menu: ReactNode
36
+ readonly open: boolean
37
+ }
38
+
39
+ interface OpenState {
40
+ readonly x: number
41
+ readonly y: number
42
+ readonly items: readonly ResolvedMenuItem[]
43
+ readonly active: number
44
+ readonly opener: HTMLElement
45
+ }
46
+
47
+ export function useContextMenu({ items, label, disabled }: ContextMenuOptions): ContextMenuResult {
48
+ const desk = useOptionalDesk()
49
+ const [state, setState] = useState<OpenState | null>(null)
50
+ const panel = useRef<HTMLDivElement>(null)
51
+ const menuId = useId()
52
+ const apple = isApplePlatform()
53
+
54
+ const show = useCallback(
55
+ (x: number, y: number, opener: HTMLElement, activeFirst: boolean) => {
56
+ const resolved = resolveMenuItems(desk, items())
57
+ if (!resolved.length) return
58
+ setState({ x, y, items: resolved, active: activeFirst ? stepMenu(resolved, -1, 1) : -1, opener })
59
+ },
60
+ [desk, items],
61
+ )
62
+
63
+ const close = useCallback((returnFocus: boolean) => {
64
+ setState(current => {
65
+ if (current && returnFocus && current.opener.isConnected) current.opener.focus({ preventScroll: true })
66
+ return null
67
+ })
68
+ }, [])
69
+
70
+ const choose = (resolved: ResolvedMenuItem | undefined) => {
71
+ if (!state || !resolved || !selectable(resolved)) return
72
+ const { opener } = state
73
+ setState(null)
74
+ // Back to the item first, so a command starts its search for a responder from there.
75
+ if (opener.isConnected) opener.focus({ preventScroll: true })
76
+ const { item } = resolved
77
+ if (item.type === 'command') perform(desk, item.command, item.args)
78
+ else if (item.type === 'action') item.onSelect()
79
+ }
80
+
81
+ const target: ContextMenuTargetProps = {
82
+ onContextMenu: event => {
83
+ if (disabled) return
84
+ event.preventDefault()
85
+ const opener = event.currentTarget
86
+ opener.focus({ preventScroll: true })
87
+ show(event.clientX, event.clientY, opener, false)
88
+ },
89
+ onKeyDown: event => {
90
+ if (disabled) return
91
+ if (event.key === 'ContextMenu' || (event.key === 'F10' && event.shiftKey)) {
92
+ event.preventDefault()
93
+ const box = event.currentTarget.getBoundingClientRect()
94
+ show(box.left + 8, box.bottom - 4, event.currentTarget, true)
95
+ }
96
+ },
97
+ }
98
+
99
+ // Keep it on screen: measured once it exists, then moved in from the edges it would cross.
100
+ useLayoutEffect(() => {
101
+ const element = panel.current
102
+ if (!state || !element) return
103
+ const { width, height } = element.getBoundingClientRect()
104
+ const x = Math.max(8, Math.min(state.x, innerWidth - width - 8))
105
+ const y = Math.max(8, state.y + height > innerHeight - 8 ? state.y - height : state.y)
106
+ element.style.left = `${x}px`
107
+ element.style.top = `${y}px`
108
+ element.focus({ preventScroll: true })
109
+ }, [state])
110
+
111
+ useEffect(() => {
112
+ if (!state) return
113
+ const onPointerDown = (event: PointerEvent) => {
114
+ if (!panel.current?.contains(event.target as Node)) close(false)
115
+ }
116
+ const onAway = () => close(false)
117
+ document.addEventListener('pointerdown', onPointerDown, true)
118
+ addEventListener('blur', onAway)
119
+ addEventListener('resize', onAway)
120
+ document.addEventListener('scroll', onAway, true)
121
+ return () => {
122
+ document.removeEventListener('pointerdown', onPointerDown, true)
123
+ removeEventListener('blur', onAway)
124
+ removeEventListener('resize', onAway)
125
+ document.removeEventListener('scroll', onAway, true)
126
+ }
127
+ }, [state, close])
128
+
129
+ const onMenuKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
130
+ if (!state) return
131
+ const move = (active: number) => setState({ ...state, active })
132
+ switch (event.key) {
133
+ case 'ArrowDown': move(stepMenu(state.items, state.active, 1)); break
134
+ case 'ArrowUp': move(stepMenu(state.items, state.active < 0 ? state.items.length : state.active, -1)); break
135
+ case 'Home': move(stepMenu(state.items, -1, 1)); break
136
+ case 'End': move(stepMenu(state.items, state.items.length, -1)); break
137
+ case 'Enter':
138
+ case ' ': choose(state.items[state.active]); break
139
+ case 'Escape': close(true); break
140
+ case 'Tab': close(true); break
141
+ default: {
142
+ if (event.key.length !== 1 || event.metaKey || event.ctrlKey || event.altKey) return
143
+ const index = typeAhead(state.items, state.active, event.key)
144
+ if (index < 0) return
145
+ move(index)
146
+ }
147
+ }
148
+ event.preventDefault()
149
+ event.stopPropagation()
150
+ }
151
+
152
+ const menu =
153
+ state && typeof document !== 'undefined'
154
+ ? createPortal(
155
+ <div
156
+ ref={panel}
157
+ role="menu"
158
+ aria-label={label}
159
+ tabIndex={-1}
160
+ className="desk-menu desk-context-menu"
161
+ style={{ left: state.x, top: state.y }}
162
+ aria-activedescendant={state.active >= 0 ? `${menuId}-${state.active}` : undefined}
163
+ onKeyDown={onMenuKeyDown}
164
+ onContextMenu={event => event.preventDefault()}
165
+ >
166
+ {state.items.map((resolved, i) => {
167
+ const { item } = resolved
168
+ if (item.type === 'separator') return <div key={i} role="separator" className="desk-menu-separator" />
169
+ if (item.type === 'header') return <div key={i} role="presentation" className="desk-menu-header">{item.label}</div>
170
+ const checkable = resolved.checked !== undefined
171
+ return (
172
+ <div
173
+ key={i}
174
+ id={`${menuId}-${i}`}
175
+ role={checkable ? 'menuitemcheckbox' : 'menuitem'}
176
+ aria-checked={checkable ? resolved.checked : undefined}
177
+ aria-disabled={!resolved.enabled || undefined}
178
+ className="desk-menu-item"
179
+ data-active={i === state.active || undefined}
180
+ onPointerMove={() => {
181
+ if (state.active !== i && selectable(resolved)) setState({ ...state, active: i })
182
+ }}
183
+ onClick={() => choose(resolved)}
184
+ >
185
+ <span className="desk-menu-check" aria-hidden="true">{resolved.checked ? '✓' : ''}</span>
186
+ <span className="desk-menu-label">{item.label}</span>
187
+ {item.detail && <span className="desk-menu-detail">{item.detail}</span>}
188
+ {item.shortcut && <kbd className="desk-menu-shortcut">{formatShortcut(item.shortcut, apple)}</kbd>}
189
+ </div>
190
+ )
191
+ })}
192
+ </div>,
193
+ document.body,
194
+ )
195
+ : null
196
+
197
+ return { target, menu, open: state !== null }
198
+ }