@liberation-data/desk 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +92 -1
  2. package/dist/core/desk.d.ts.map +1 -1
  3. package/dist/core/desk.js +19 -2
  4. package/dist/core/desk.js.map +1 -1
  5. package/dist/core/index.d.ts +2 -0
  6. package/dist/core/index.d.ts.map +1 -1
  7. package/dist/core/index.js +1 -0
  8. package/dist/core/index.js.map +1 -1
  9. package/dist/core/layouts.d.ts +26 -0
  10. package/dist/core/layouts.d.ts.map +1 -0
  11. package/dist/core/layouts.js +70 -0
  12. package/dist/core/layouts.js.map +1 -0
  13. package/dist/core/types.d.ts +3 -0
  14. package/dist/core/types.d.ts.map +1 -1
  15. package/dist/desk.css +146 -1
  16. package/dist/react/Desktop.d.ts +7 -1
  17. package/dist/react/Desktop.d.ts.map +1 -1
  18. package/dist/react/Desktop.js +4 -4
  19. package/dist/react/Desktop.js.map +1 -1
  20. package/dist/react/Dock.d.ts +16 -1
  21. package/dist/react/Dock.d.ts.map +1 -1
  22. package/dist/react/Dock.js +44 -6
  23. package/dist/react/Dock.js.map +1 -1
  24. package/dist/react/MenuBar.d.ts +16 -0
  25. package/dist/react/MenuBar.d.ts.map +1 -1
  26. package/dist/react/MenuBar.js +36 -27
  27. package/dist/react/MenuBar.js.map +1 -1
  28. package/dist/react/context.d.ts +2 -0
  29. package/dist/react/context.d.ts.map +1 -1
  30. package/dist/react/context.js +2 -0
  31. package/dist/react/context.js.map +1 -1
  32. package/dist/react/contextMenu.d.ts +22 -0
  33. package/dist/react/contextMenu.d.ts.map +1 -0
  34. package/dist/react/contextMenu.js +146 -0
  35. package/dist/react/contextMenu.js.map +1 -0
  36. package/dist/react/iconView.d.ts +34 -0
  37. package/dist/react/iconView.d.ts.map +1 -0
  38. package/dist/react/iconView.js +112 -0
  39. package/dist/react/iconView.js.map +1 -0
  40. package/dist/react/index.d.ts +6 -2
  41. package/dist/react/index.d.ts.map +1 -1
  42. package/dist/react/index.js +3 -1
  43. package/dist/react/index.js.map +1 -1
  44. package/llms.txt +22 -4
  45. package/package.json +1 -1
  46. package/src/core/desk.ts +17 -2
  47. package/src/core/index.ts +2 -0
  48. package/src/core/layouts.ts +88 -0
  49. package/src/core/types.ts +4 -0
  50. package/src/desk.css +146 -1
  51. package/src/react/Desktop.tsx +11 -2
  52. package/src/react/Dock.tsx +74 -5
  53. package/src/react/MenuBar.tsx +43 -30
  54. package/src/react/context.tsx +3 -0
  55. package/src/react/contextMenu.tsx +198 -0
  56. package/src/react/iconView.tsx +206 -0
  57. package/src/react/index.ts +6 -2
@@ -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
+ }
@@ -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 { Dock, dockItem, dockSeparator, dockStack } from './Dock.js'
64
- export type { DockEntry, DockItem, DockProps, DockStack } from './Dock.js'
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'