@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
|
@@ -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
|
+
}
|
package/src/react/index.ts
CHANGED
|
@@ -59,6 +59,10 @@ export type { ComposerProps, Message, ThreadProps } from './conversation.js'
|
|
|
59
59
|
export { Alert, Popover, Sheet, ToastProvider, useToast } from './overlays.js'
|
|
60
60
|
export type { AlertProps, PopoverProps, PopoverTriggerProps, SheetProps, Toast, ToastInput } from './overlays.js'
|
|
61
61
|
export { MenuBar, menuAction, menuCommand, menuHeader, menuSeparator, windowMenuItems } from './MenuBar.js'
|
|
62
|
+
export { useContextMenu } from './contextMenu.js'
|
|
63
|
+
export type { ContextMenuOptions, ContextMenuResult, ContextMenuTargetProps } from './contextMenu.js'
|
|
62
64
|
export type { Menu, MenuBarProps, MenuItem } from './MenuBar.js'
|
|
63
|
-
export {
|
|
64
|
-
export type {
|
|
65
|
+
export { IconView } from './iconView.js'
|
|
66
|
+
export type { IconViewItem, IconViewProps } from './iconView.js'
|
|
67
|
+
export { Dock, DOCK_ITEM, dockItem, dockSeparator, dockStack } from './Dock.js'
|
|
68
|
+
export type { DockEntry, DockItem, DockPins, DockProps, DockStack } from './Dock.js'
|