@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.
- package/README.md +92 -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 +146 -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 +6 -2
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +3 -1
- package/dist/react/index.js.map +1 -1
- package/llms.txt +22 -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 +146 -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 +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
|
+
}
|
package/src/react/Desktop.tsx
CHANGED
|
@@ -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>
|
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
|
+
}
|