@adea-ai/ui 0.144.0 → 0.145.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 (28) hide show
  1. package/dist/components/composites/workspace-switcher/index.d.ts +2 -0
  2. package/dist/components/composites/workspace-switcher/index.d.ts.map +1 -1
  3. package/dist/components/composites/workspace-switcher/index.js +3 -1
  4. package/dist/components/composites/workspace-switcher/workspace-order.d.ts +16 -0
  5. package/dist/components/composites/workspace-switcher/workspace-order.d.ts.map +1 -0
  6. package/dist/components/composites/workspace-switcher/workspace-order.js +24 -0
  7. package/dist/components/composites/workspace-switcher/workspace-order.js.map +1 -0
  8. package/dist/components/composites/workspace-switcher/workspace-reorder-dialog.d.ts +31 -0
  9. package/dist/components/composites/workspace-switcher/workspace-reorder-dialog.d.ts.map +1 -0
  10. package/dist/components/composites/workspace-switcher/workspace-reorder-dialog.js +198 -0
  11. package/dist/components/composites/workspace-switcher/workspace-reorder-dialog.js.map +1 -0
  12. package/dist/components/composites/workspace-switcher/workspace-switcher.d.ts +9 -0
  13. package/dist/components/composites/workspace-switcher/workspace-switcher.d.ts.map +1 -1
  14. package/dist/components/composites/workspace-switcher/workspace-switcher.js +39 -4
  15. package/dist/components/composites/workspace-switcher/workspace-switcher.js.map +1 -1
  16. package/dist/index.js +3 -1
  17. package/dist/r/registry.json +12 -0
  18. package/dist/r/src/components/composites/workspace-switcher/index.ts +5 -0
  19. package/dist/r/src/components/composites/workspace-switcher/workspace-order.ts +38 -0
  20. package/dist/r/src/components/composites/workspace-switcher/workspace-reorder-dialog.tsx +204 -0
  21. package/dist/r/src/components/composites/workspace-switcher/workspace-switcher.tsx +94 -60
  22. package/dist/r/workspace-switcher.json +12 -0
  23. package/package.json +1 -1
  24. package/registry.json +12 -0
  25. package/src/components/composites/workspace-switcher/index.ts +5 -0
  26. package/src/components/composites/workspace-switcher/workspace-order.ts +38 -0
  27. package/src/components/composites/workspace-switcher/workspace-reorder-dialog.tsx +204 -0
  28. package/src/components/composites/workspace-switcher/workspace-switcher.tsx +94 -60
@@ -0,0 +1,204 @@
1
+ import { ArrowDown, ArrowUp } from 'lucide-solid'
2
+ import { For, Show, createEffect, createSignal, on, onCleanup, type Accessor } from 'solid-js'
3
+ import { Button } from '../../ui/button'
4
+ import {
5
+ Dialog,
6
+ DialogClose,
7
+ DialogContent,
8
+ DialogDescription,
9
+ DialogFooter,
10
+ DialogHeader,
11
+ DialogTitle,
12
+ } from '../../ui/dialog'
13
+ import { createDialogFocusRestoration } from '../../ui/dialog/dialog'
14
+ import { ActionButton } from '../action-button'
15
+ import { moveTarget, moveWorkspace, type WorkspaceMoveDirection } from './workspace-order'
16
+
17
+ export type WorkspaceReorderItem = {
18
+ id: string
19
+ name: string
20
+ fixed?: boolean
21
+ }
22
+
23
+ export type WorkspaceReorderDialogProps = {
24
+ open: boolean
25
+ onOpenChange(open: boolean): void
26
+ workspaces: readonly WorkspaceReorderItem[]
27
+ /** Receives the full id order after one move. May be async; a rejection shows an error. */
28
+ onReorder(ids: readonly string[]): void | Promise<void>
29
+ title?: string
30
+ /** Where focus returns on close; the menu item that opened the dialog is gone by then. */
31
+ restoreFocusRef?: Accessor<HTMLElement | undefined>
32
+ }
33
+
34
+ /**
35
+ * WorkspaceReorderDialog.
36
+ *
37
+ * Ordering by buttons, not by dragging, so it works from the keyboard and with
38
+ * a screen reader: every row has a labelled Move up and Move down. Each move is
39
+ * reported to the host straight away (there is no Save), the moved row keeps
40
+ * focus on the control that was used, and the new position is announced.
41
+ *
42
+ * One save runs at a time, even across a close and reopen: the moves stay
43
+ * disabled until the host's promise settles. A save that finishes after the
44
+ * dialog has closed reports nothing here (the host owns any failure it wants
45
+ * to surface) and never moves focus.
46
+ */
47
+ export function WorkspaceReorderDialog(props: WorkspaceReorderDialogProps) {
48
+ const [status, setStatus] = createSignal('')
49
+ const [error, setError] = createSignal('')
50
+ const [pending, setPending] = createSignal(false)
51
+ let list: HTMLOListElement | undefined
52
+ // Each open and each close starts a new session. A save belongs to the session
53
+ // it began in; once the dialog has closed (or closed and reopened) its result
54
+ // no longer speaks for what is on screen, so it must not touch the status
55
+ // line or focus. `pending` is deliberately not reset with the session: the
56
+ // host's save is still running, so the next move waits for it instead of
57
+ // running a second save beside it.
58
+ let session = 0
59
+ let disposed = false
60
+ let frame: number | undefined
61
+
62
+ createEffect(
63
+ on(
64
+ () => props.open,
65
+ () => {
66
+ session += 1
67
+ if (frame !== undefined) cancelAnimationFrame(frame)
68
+ frame = undefined
69
+ setStatus('')
70
+ setError('')
71
+ },
72
+ { defer: true }
73
+ )
74
+ )
75
+ onCleanup(() => {
76
+ disposed = true
77
+ session += 1
78
+ if (frame !== undefined) cancelAnimationFrame(frame)
79
+ })
80
+
81
+ const focus = createDialogFocusRestoration({
82
+ open: () => props.open,
83
+ restoreFocusRef: props.restoreFocusRef,
84
+ })
85
+
86
+ const focusMove = (owner: number, id: string, direction: WorkspaceMoveDirection) => {
87
+ // The host re-renders the rows with the new order; focus the same control
88
+ // once it exists, or its sibling when the move reached an edge.
89
+ if (frame !== undefined) cancelAnimationFrame(frame)
90
+ frame = requestAnimationFrame(() => {
91
+ frame = undefined
92
+ if (disposed || owner !== session || !props.open) return
93
+ const row = list?.querySelector<HTMLElement>(`[data-reorder-id="${CSS.escape(id)}"]`)
94
+ const wanted = row?.querySelector<HTMLElement>(`[data-move="${direction}"]`)
95
+ const other = row?.querySelector<HTMLElement>(
96
+ `[data-move="${direction === 'up' ? 'down' : 'up'}"]`
97
+ )
98
+ const target = wanted?.getAttribute('aria-disabled') === 'true' ? other : wanted
99
+ target?.focus({ preventScroll: true })
100
+ })
101
+ }
102
+
103
+ const move = async (item: WorkspaceReorderItem, direction: WorkspaceMoveDirection) => {
104
+ if (pending()) return
105
+ const next = moveWorkspace(props.workspaces, item.id, direction)
106
+ if (!next) return
107
+ const owner = session
108
+ const current = () => !disposed && owner === session && props.open
109
+ setError('')
110
+ setPending(true)
111
+ try {
112
+ await props.onReorder(next)
113
+ if (current())
114
+ setStatus(`Moved ${item.name} to position ${next.indexOf(item.id) + 1} of ${next.length}.`)
115
+ } catch {
116
+ if (current()) {
117
+ setStatus('')
118
+ setError('Could not save the order. Try again.')
119
+ }
120
+ } finally {
121
+ if (!disposed) setPending(false)
122
+ if (current()) focusMove(owner, item.id, direction)
123
+ }
124
+ }
125
+
126
+ return (
127
+ <Dialog open={props.open} onOpenChange={props.onOpenChange}>
128
+ <DialogContent
129
+ class="w-full max-w-sm"
130
+ onKeyDown={(event: KeyboardEvent) => {
131
+ // Tooltip help on a focused move control would otherwise take the first Escape.
132
+ if (event.key === 'Escape' && !event.defaultPrevented) {
133
+ event.preventDefault()
134
+ props.onOpenChange(false)
135
+ }
136
+ }}
137
+ onOpenAutoFocus={focus.onOpenAutoFocus}
138
+ onCloseAutoFocus={focus.onCloseAutoFocus}
139
+ >
140
+ <DialogHeader>
141
+ <DialogTitle>{props.title ?? 'Reorder workspaces'}</DialogTitle>
142
+ <DialogDescription>
143
+ Move a workspace up or down. The order is saved as you go.
144
+ </DialogDescription>
145
+ </DialogHeader>
146
+ <ol
147
+ ref={(el) => (list = el)}
148
+ class="flex flex-col gap-1"
149
+ aria-label="Workspace order"
150
+ aria-busy={pending()}
151
+ >
152
+ <For each={props.workspaces}>
153
+ {(item) => (
154
+ <li
155
+ data-reorder-id={item.id}
156
+ class="flex items-center gap-2 rounded-md border border-border px-3 py-1.5"
157
+ >
158
+ <span class="min-w-0 flex-1 truncate text-sm">{item.name}</span>
159
+ <Show
160
+ when={!item.fixed}
161
+ fallback={<span class="text-xs text-muted-foreground">Always first</span>}
162
+ >
163
+ <For each={['up', 'down'] as const}>
164
+ {(direction) => (
165
+ <ActionButton
166
+ variant="ghost"
167
+ size="icon-sm"
168
+ data-move={direction}
169
+ aria-label={`Move ${item.name} ${direction}`}
170
+ tooltip={`Move ${direction}`}
171
+ disabled={
172
+ pending() ||
173
+ moveTarget(props.workspaces, item.id, direction) === undefined
174
+ }
175
+ onClick={() => void move(item, direction)}
176
+ >
177
+ <Show when={direction === 'up'} fallback={<ArrowDown aria-hidden="true" />}>
178
+ <ArrowUp aria-hidden="true" />
179
+ </Show>
180
+ </ActionButton>
181
+ )}
182
+ </For>
183
+ </Show>
184
+ </li>
185
+ )}
186
+ </For>
187
+ </ol>
188
+ <p role="status" aria-live="polite" class="min-h-5 text-xs text-muted-foreground">
189
+ {status()}
190
+ </p>
191
+ <Show when={error()}>
192
+ <p role="alert" class="text-xs text-destructive">
193
+ {error()}
194
+ </p>
195
+ </Show>
196
+ <DialogFooter>
197
+ <DialogClose as={Button} variant="outline" size="sm">
198
+ Done
199
+ </DialogClose>
200
+ </DialogFooter>
201
+ </DialogContent>
202
+ </Dialog>
203
+ )
204
+ }
@@ -1,4 +1,4 @@
1
- import { ChevronDown, Plus, Settings } from 'lucide-solid'
1
+ import { ArrowDownUp, ChevronDown, Plus, Settings } from 'lucide-solid'
2
2
  import type { JSX } from 'solid-js'
3
3
  import { For, Show, createSignal } from 'solid-js'
4
4
  import { cn } from '#lib/utils'
@@ -14,6 +14,7 @@ import {
14
14
  DropdownMenuTrigger,
15
15
  } from '../../ui/dropdown-menu'
16
16
  import { ActionButton } from '../action-button'
17
+ import { WorkspaceReorderDialog } from './workspace-reorder-dialog'
17
18
 
18
19
  /**
19
20
  * WorkspaceSwitcher.
@@ -37,6 +38,8 @@ export type WorkspaceSwitcherItem = {
37
38
  mark: JSX.Element
38
39
  /** A quiet trailing readout in the row, e.g. an unread count. Optional. */
39
40
  detail?: JSX.Element
41
+ /** Pinned to the top of the order, e.g. Home: it cannot be moved and nothing moves above it. */
42
+ fixed?: boolean
40
43
  }
41
44
 
42
45
  export type WorkspaceSwitcherProps = {
@@ -50,6 +53,13 @@ export type WorkspaceSwitcherProps = {
50
53
  /** Offered beside the create action when present: open the current workspace's settings. */
51
54
  onManage?(): void
52
55
  manageLabel?: string
56
+ /**
57
+ * Offered when present and at least two workspaces can move: opens a dialog
58
+ * with Move up and Move down on each row. Receives the full id order after
59
+ * each move; the host saves it and passes the workspaces back in that order.
60
+ */
61
+ onReorder?(ids: readonly string[]): void | Promise<void>
62
+ reorderLabel?: string
53
63
  /** The menu's heading; also the trigger's accessible-name prefix. */
54
64
  label?: string
55
65
  /** Shown while no workspace is current. */
@@ -60,69 +70,93 @@ export type WorkspaceSwitcherProps = {
60
70
 
61
71
  export function WorkspaceSwitcher(props: WorkspaceSwitcherProps) {
62
72
  const [open, setOpen] = createSignal(false)
73
+ const [reordering, setReordering] = createSignal(false)
74
+ let trigger: HTMLElement | undefined
63
75
  const current = () => props.workspaces.find((item) => item.id === props.value)
64
76
  const label = () => props.label ?? 'Workspaces'
77
+ const canReorder = () =>
78
+ props.onReorder !== undefined && props.workspaces.filter((item) => !item.fixed).length > 1
65
79
 
66
80
  return (
67
- <DropdownMenu modal={false} open={open()} onOpenChange={setOpen}>
68
- <DropdownMenuTrigger
69
- as={ActionButton}
70
- variant="ghost"
71
- size="sm"
72
- disabled={props.disabled}
73
- data-slot="workspace-switcher"
74
- tooltip={`Workspace: ${current()?.name ?? props.placeholder ?? 'Choose workspace'}`}
75
- aria-label={`Switch workspace, current: ${current()?.name ?? 'none'}`}
76
- class={cn('max-w-full min-w-0 shrink', props.class)}
77
- >
78
- <span aria-hidden="true" class="flex shrink-0">
79
- {current()?.mark}
80
- </span>
81
- <span class="min-w-0 max-w-40 truncate">
82
- {current()?.name ?? props.placeholder ?? 'Choose workspace'}
83
- </span>
84
- <ChevronDown aria-hidden="true" class="size-3.5 shrink-0 text-muted-foreground" />
85
- </DropdownMenuTrigger>
86
- <DropdownMenuContent align="start" sideOffset={6} class="min-w-56">
87
- <DropdownMenuGroup>
88
- <DropdownMenuLabel>{label()}</DropdownMenuLabel>
89
- <DropdownMenuRadioGroup
90
- value={props.value}
91
- onChange={(value) => {
92
- setOpen(false)
93
- const id = String(value)
94
- if (id !== props.value) props.onValueChange(id)
95
- }}
96
- >
97
- <For each={props.workspaces}>
98
- {(item) => (
99
- <DropdownMenuRadioItem value={item.id} closeOnSelect data-workspace-id={item.id}>
100
- <span aria-hidden="true" class="flex shrink-0">
101
- {item.mark}
102
- </span>
103
- <span class="min-w-0 flex-1 truncate">{item.name}</span>
104
- <Show when={item.detail}>{item.detail}</Show>
105
- </DropdownMenuRadioItem>
106
- )}
107
- </For>
108
- </DropdownMenuRadioGroup>
109
- </DropdownMenuGroup>
110
- <Show when={props.onCreate || props.onManage}>
111
- <DropdownMenuSeparator />
112
- <Show when={props.onCreate}>
113
- <DropdownMenuItem onSelect={() => props.onCreate?.()}>
114
- <Plus aria-hidden="true" />
115
- {props.createLabel ?? 'New workspace'}
116
- </DropdownMenuItem>
81
+ <>
82
+ <DropdownMenu modal={false} open={open()} onOpenChange={setOpen}>
83
+ <DropdownMenuTrigger
84
+ as={ActionButton}
85
+ variant="ghost"
86
+ size="sm"
87
+ disabled={props.disabled}
88
+ ref={(el: HTMLElement) => (trigger = el)}
89
+ data-slot="workspace-switcher"
90
+ tooltip={`Workspace: ${current()?.name ?? props.placeholder ?? 'Choose workspace'}`}
91
+ aria-label={`Switch workspace, current: ${current()?.name ?? 'none'}`}
92
+ class={cn('max-w-full min-w-0 shrink', props.class)}
93
+ >
94
+ <span aria-hidden="true" class="flex shrink-0">
95
+ {current()?.mark}
96
+ </span>
97
+ <span class="min-w-0 max-w-40 truncate">
98
+ {current()?.name ?? props.placeholder ?? 'Choose workspace'}
99
+ </span>
100
+ <ChevronDown aria-hidden="true" class="size-3.5 shrink-0 text-muted-foreground" />
101
+ </DropdownMenuTrigger>
102
+ <DropdownMenuContent align="start" sideOffset={6} class="min-w-56">
103
+ <DropdownMenuGroup>
104
+ <DropdownMenuLabel>{label()}</DropdownMenuLabel>
105
+ <DropdownMenuRadioGroup
106
+ value={props.value}
107
+ onChange={(value) => {
108
+ setOpen(false)
109
+ const id = String(value)
110
+ if (id !== props.value) props.onValueChange(id)
111
+ }}
112
+ >
113
+ <For each={props.workspaces}>
114
+ {(item) => (
115
+ <DropdownMenuRadioItem value={item.id} closeOnSelect data-workspace-id={item.id}>
116
+ <span aria-hidden="true" class="flex shrink-0">
117
+ {item.mark}
118
+ </span>
119
+ <span class="min-w-0 flex-1 truncate">{item.name}</span>
120
+ <Show when={item.detail}>{item.detail}</Show>
121
+ </DropdownMenuRadioItem>
122
+ )}
123
+ </For>
124
+ </DropdownMenuRadioGroup>
125
+ </DropdownMenuGroup>
126
+ <Show when={props.onCreate || props.onManage || canReorder()}>
127
+ <DropdownMenuSeparator />
128
+ <Show when={props.onCreate}>
129
+ <DropdownMenuItem onSelect={() => props.onCreate?.()}>
130
+ <Plus aria-hidden="true" />
131
+ {props.createLabel ?? 'New workspace'}
132
+ </DropdownMenuItem>
133
+ </Show>
134
+ <Show when={canReorder()}>
135
+ <DropdownMenuItem onSelect={() => setReordering(true)}>
136
+ <ArrowDownUp aria-hidden="true" />
137
+ {props.reorderLabel ?? 'Reorder workspaces…'}
138
+ </DropdownMenuItem>
139
+ </Show>
140
+ <Show when={props.onManage}>
141
+ <DropdownMenuItem onSelect={() => props.onManage?.()}>
142
+ <Settings aria-hidden="true" />
143
+ {props.manageLabel ?? 'Workspace settings…'}
144
+ </DropdownMenuItem>
145
+ </Show>
117
146
  </Show>
118
- <Show when={props.onManage}>
119
- <DropdownMenuItem onSelect={() => props.onManage?.()}>
120
- <Settings aria-hidden="true" />
121
- {props.manageLabel ?? 'Workspace settings…'}
122
- </DropdownMenuItem>
123
- </Show>
124
- </Show>
125
- </DropdownMenuContent>
126
- </DropdownMenu>
147
+ </DropdownMenuContent>
148
+ </DropdownMenu>
149
+ <Show when={props.onReorder}>
150
+ {(onReorder) => (
151
+ <WorkspaceReorderDialog
152
+ open={reordering()}
153
+ onOpenChange={setReordering}
154
+ workspaces={props.workspaces}
155
+ onReorder={onReorder()}
156
+ restoreFocusRef={() => trigger}
157
+ />
158
+ )}
159
+ </Show>
160
+ </>
127
161
  )
128
162
  }