@liberation-data/desk 0.1.0 → 0.3.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 (67) hide show
  1. package/README.md +126 -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 +252 -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 +10 -2
  41. package/dist/react/index.d.ts.map +1 -1
  42. package/dist/react/index.js +5 -1
  43. package/dist/react/index.js.map +1 -1
  44. package/dist/react/infoTip.d.ts +11 -0
  45. package/dist/react/infoTip.d.ts.map +1 -0
  46. package/dist/react/infoTip.js +8 -0
  47. package/dist/react/infoTip.js.map +1 -0
  48. package/dist/react/pane.d.ts +28 -0
  49. package/dist/react/pane.d.ts.map +1 -0
  50. package/dist/react/pane.js +9 -0
  51. package/dist/react/pane.js.map +1 -0
  52. package/llms.txt +34 -4
  53. package/package.json +1 -1
  54. package/src/core/desk.ts +17 -2
  55. package/src/core/index.ts +2 -0
  56. package/src/core/layouts.ts +88 -0
  57. package/src/core/types.ts +4 -0
  58. package/src/desk.css +252 -1
  59. package/src/react/Desktop.tsx +11 -2
  60. package/src/react/Dock.tsx +74 -5
  61. package/src/react/MenuBar.tsx +43 -30
  62. package/src/react/context.tsx +3 -0
  63. package/src/react/contextMenu.tsx +198 -0
  64. package/src/react/iconView.tsx +206 -0
  65. package/src/react/index.ts +10 -2
  66. package/src/react/infoTip.tsx +48 -0
  67. package/src/react/pane.tsx +65 -0
@@ -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
+ }
@@ -0,0 +1,206 @@
1
+ import { useEffect, useRef, useState } from 'react'
2
+ import type { KeyboardEvent as ReactKeyboardEvent, ReactNode } from 'react'
3
+ import { useContextMenu } from './contextMenu.js'
4
+ import { useDragSource } from './dnd.js'
5
+ import type { MenuItem } from './MenuBar.js'
6
+
7
+ /*
8
+ * Things shown as icons, the way a Finder window shows files: a name under each, arranged in rows
9
+ * that follow the width of the window.
10
+ *
11
+ * Click selects and double-click opens, as a Finder does; `openOn="single"` opens on one click for
12
+ * a launcher, where selecting first would be a step nobody wants. The keyboard can do everything the
13
+ * pointer can: arrows move through the grid (up and down by a row, however many fit), Home and End
14
+ * go to the ends, typing a name jumps to it, Return opens, and the Menu key opens the item's menu.
15
+ */
16
+
17
+ export interface IconViewItem {
18
+ readonly id: string
19
+ readonly label: string
20
+ readonly icon: ReactNode
21
+ /** A second line, muted: a kind, a scope, a size. */
22
+ readonly subtitle?: string
23
+ readonly badge?: ReactNode
24
+ readonly disabled?: boolean
25
+ }
26
+
27
+ export interface IconViewProps<T extends IconViewItem> {
28
+ readonly items: readonly T[]
29
+ /** Names the collection: "Apps". */
30
+ readonly label: string
31
+ readonly onOpen: (item: T) => void
32
+ /** `double` (the default): click selects, double-click or Return opens. `single`: a click opens. */
33
+ readonly openOn?: 'double' | 'single'
34
+ /** Controlled selection. Leave both out and the view keeps its own. */
35
+ readonly selected?: string | null
36
+ readonly onSelectionChange?: (id: string | null) => void
37
+ /** The item's context menu: what can be done to this one thing. */
38
+ readonly contextMenu?: (item: T) => readonly MenuItem[]
39
+ /** Lets an icon be picked up and carried, to the dock or into another window. */
40
+ readonly drag?: { readonly type: string; readonly payload: (item: T) => unknown }
41
+ /** Shown when there are no items. */
42
+ readonly empty?: ReactNode
43
+ readonly className?: string
44
+ }
45
+
46
+ export function IconView<T extends IconViewItem>({
47
+ items,
48
+ label,
49
+ onOpen,
50
+ openOn = 'double',
51
+ selected,
52
+ onSelectionChange,
53
+ contextMenu,
54
+ drag,
55
+ empty,
56
+ className,
57
+ }: IconViewProps<T>) {
58
+ const [ownSelection, setOwnSelection] = useState<string | null>(null)
59
+ const current = selected !== undefined ? selected : ownSelection
60
+ const select = (id: string | null) => {
61
+ if (selected === undefined) setOwnSelection(id)
62
+ onSelectionChange?.(id)
63
+ }
64
+ const list = useRef<HTMLUListElement>(null)
65
+ const typed = useRef({ text: '', at: 0 })
66
+ const source = useDragSource<unknown>({ type: drag?.type ?? 'desk.icon', disabled: !drag })
67
+
68
+ // The one that takes Tab: the selected item, or the first.
69
+ const focusable = items.some(i => i.id === current) ? current : (items[0]?.id ?? null)
70
+
71
+ const cells = () => [...(list.current?.querySelectorAll<HTMLElement>('[data-icon-id]') ?? [])]
72
+
73
+ const focusAt = (index: number) => {
74
+ const all = cells()
75
+ const target = all[Math.max(0, Math.min(index, all.length - 1))]
76
+ if (!target) return
77
+ target.focus()
78
+ select(target.dataset.iconId ?? null)
79
+ }
80
+
81
+ // How many icons fit in a row right now: the ones that share the first one's top edge.
82
+ const columns = () => {
83
+ const all = cells()
84
+ const top = all[0]?.offsetTop
85
+ const count = all.filter(cell => cell.offsetTop === top).length
86
+ return Math.max(1, count)
87
+ }
88
+
89
+ const onKeyDown = (event: ReactKeyboardEvent<HTMLUListElement>) => {
90
+ const all = cells()
91
+ const index = all.findIndex(cell => cell === document.activeElement)
92
+ if (index < 0) return
93
+ const item = items[index]
94
+ switch (event.key) {
95
+ case 'ArrowRight': focusAt(index + 1); break
96
+ case 'ArrowLeft': focusAt(index - 1); break
97
+ case 'ArrowDown': focusAt(index + columns()); break
98
+ case 'ArrowUp': focusAt(index - columns()); break
99
+ case 'Home': focusAt(0); break
100
+ case 'End': focusAt(all.length - 1); break
101
+ case 'Enter':
102
+ if (item && !item.disabled) onOpen(item)
103
+ break
104
+ case ' ':
105
+ if (item) select(item.id)
106
+ break
107
+ default: {
108
+ if (event.key.length !== 1 || event.metaKey || event.ctrlKey || event.altKey) return
109
+ // Typing a name finds it: letters typed close together build one search, as in a Finder.
110
+ const now = Date.now()
111
+ typed.current = { text: (now - typed.current.at < 700 ? typed.current.text : '') + event.key.toLowerCase(), at: now }
112
+ const search = typed.current.text
113
+ const order = [...items.slice(index + (search.length === 1 ? 1 : 0)), ...items.slice(0, index + (search.length === 1 ? 1 : 0))]
114
+ const found = order.find(i => i.label.toLowerCase().startsWith(search))
115
+ if (!found) return
116
+ focusAt(items.indexOf(found))
117
+ }
118
+ }
119
+ event.preventDefault()
120
+ }
121
+
122
+ // A selection that no longer exists is dropped, so Tab never lands nowhere.
123
+ useEffect(() => {
124
+ if (current && !items.some(i => i.id === current)) select(null)
125
+ // eslint-disable-next-line react-hooks/exhaustive-deps
126
+ }, [items, current])
127
+
128
+ if (!items.length) return <div className={['desk-icon-view-empty', className].filter(Boolean).join(' ')}>{empty}</div>
129
+
130
+ return (
131
+ <ul
132
+ ref={list}
133
+ role="listbox"
134
+ aria-label={label}
135
+ className={['desk-icon-view', className].filter(Boolean).join(' ')}
136
+ onKeyDown={onKeyDown}
137
+ onPointerDown={event => {
138
+ if (event.target === event.currentTarget) select(null)
139
+ }}
140
+ >
141
+ {items.map(item => (
142
+ <IconCell
143
+ key={item.id}
144
+ item={item}
145
+ selected={item.id === current}
146
+ tabbable={item.id === focusable}
147
+ openOn={openOn}
148
+ onSelect={() => select(item.id)}
149
+ onOpen={() => onOpen(item)}
150
+ menu={contextMenu ? () => contextMenu(item) : null}
151
+ dragProps={drag ? source.dragProps(drag.payload(item), <span className="desk-icon-drag">{item.icon}</span>) : null}
152
+ />
153
+ ))}
154
+ </ul>
155
+ )
156
+ }
157
+
158
+ interface IconCellProps {
159
+ readonly item: IconViewItem
160
+ readonly selected: boolean
161
+ readonly tabbable: boolean
162
+ readonly openOn: 'double' | 'single'
163
+ readonly onSelect: () => void
164
+ readonly onOpen: () => void
165
+ readonly menu: (() => readonly MenuItem[]) | null
166
+ readonly dragProps: ReturnType<ReturnType<typeof useDragSource>['dragProps']> | null
167
+ }
168
+
169
+ function IconCell({ item, selected, tabbable, openOn, onSelect, onOpen, menu, dragProps }: IconCellProps) {
170
+ const context = useContextMenu({ label: `Actions for ${item.label}`, items: menu ?? (() => []), disabled: !menu })
171
+ // The menu sits beside the cell, not in it: React bubbles a portal's events through its owner,
172
+ // and a click on "Open" must not also count as a click on the icon.
173
+ return (
174
+ <>
175
+ <li
176
+ role="option"
177
+ aria-selected={selected}
178
+ aria-disabled={item.disabled || undefined}
179
+ tabIndex={tabbable ? 0 : -1}
180
+ data-icon-id={item.id}
181
+ className="desk-icon-cell"
182
+ {...(dragProps ?? {})}
183
+ onClick={() => {
184
+ onSelect()
185
+ if (openOn === 'single' && !item.disabled) onOpen()
186
+ }}
187
+ onDoubleClick={() => {
188
+ if (openOn === 'double' && !item.disabled) onOpen()
189
+ }}
190
+ onContextMenu={event => {
191
+ onSelect()
192
+ context.target.onContextMenu(event)
193
+ }}
194
+ onKeyDown={context.target.onKeyDown}
195
+ >
196
+ <span className="desk-icon-glyph" aria-hidden="true">
197
+ {item.icon}
198
+ {item.badge != null && <span className="desk-icon-badge">{item.badge}</span>}
199
+ </span>
200
+ <span className="desk-icon-label">{item.label}</span>
201
+ {item.subtitle && <span className="desk-icon-subtitle">{item.subtitle}</span>}
202
+ </li>
203
+ {context.menu}
204
+ </>
205
+ )
206
+ }