@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.
- package/README.md +126 -1
- package/dist/core/desk.d.ts.map +1 -1
- package/dist/core/desk.js +19 -2
- package/dist/core/desk.js.map +1 -1
- package/dist/core/index.d.ts +2 -0
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +1 -0
- package/dist/core/index.js.map +1 -1
- package/dist/core/layouts.d.ts +26 -0
- package/dist/core/layouts.d.ts.map +1 -0
- package/dist/core/layouts.js +70 -0
- package/dist/core/layouts.js.map +1 -0
- package/dist/core/types.d.ts +3 -0
- package/dist/core/types.d.ts.map +1 -1
- package/dist/desk.css +252 -1
- package/dist/react/Desktop.d.ts +7 -1
- package/dist/react/Desktop.d.ts.map +1 -1
- package/dist/react/Desktop.js +4 -4
- package/dist/react/Desktop.js.map +1 -1
- package/dist/react/Dock.d.ts +16 -1
- package/dist/react/Dock.d.ts.map +1 -1
- package/dist/react/Dock.js +44 -6
- package/dist/react/Dock.js.map +1 -1
- package/dist/react/MenuBar.d.ts +16 -0
- package/dist/react/MenuBar.d.ts.map +1 -1
- package/dist/react/MenuBar.js +36 -27
- package/dist/react/MenuBar.js.map +1 -1
- package/dist/react/context.d.ts +2 -0
- package/dist/react/context.d.ts.map +1 -1
- package/dist/react/context.js +2 -0
- package/dist/react/context.js.map +1 -1
- package/dist/react/contextMenu.d.ts +22 -0
- package/dist/react/contextMenu.d.ts.map +1 -0
- package/dist/react/contextMenu.js +146 -0
- package/dist/react/contextMenu.js.map +1 -0
- package/dist/react/iconView.d.ts +34 -0
- package/dist/react/iconView.d.ts.map +1 -0
- package/dist/react/iconView.js +112 -0
- package/dist/react/iconView.js.map +1 -0
- package/dist/react/index.d.ts +10 -2
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +5 -1
- package/dist/react/index.js.map +1 -1
- package/dist/react/infoTip.d.ts +11 -0
- package/dist/react/infoTip.d.ts.map +1 -0
- package/dist/react/infoTip.js +8 -0
- package/dist/react/infoTip.js.map +1 -0
- package/dist/react/pane.d.ts +28 -0
- package/dist/react/pane.d.ts.map +1 -0
- package/dist/react/pane.js +9 -0
- package/dist/react/pane.js.map +1 -0
- package/llms.txt +34 -4
- package/package.json +1 -1
- package/src/core/desk.ts +17 -2
- package/src/core/index.ts +2 -0
- package/src/core/layouts.ts +88 -0
- package/src/core/types.ts +4 -0
- package/src/desk.css +252 -1
- package/src/react/Desktop.tsx +11 -2
- package/src/react/Dock.tsx +74 -5
- package/src/react/MenuBar.tsx +43 -30
- package/src/react/context.tsx +3 -0
- package/src/react/contextMenu.tsx +198 -0
- package/src/react/iconView.tsx +206 -0
- package/src/react/index.ts +10 -2
- package/src/react/infoTip.tsx +48 -0
- package/src/react/pane.tsx +65 -0
package/src/react/Dock.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
129
|
-
|
|
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
|
|
package/src/react/MenuBar.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
})()
|
package/src/react/context.tsx
CHANGED
|
@@ -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
|
+
}
|