@adea-ai/ui 0.142.3 → 0.143.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/dist/components/composites/accent-picker/accent-picker.d.ts +28 -0
- package/dist/components/composites/accent-picker/accent-picker.d.ts.map +1 -0
- package/dist/components/composites/accent-picker/accent-picker.js +97 -0
- package/dist/components/composites/accent-picker/accent-picker.js.map +1 -0
- package/dist/components/composites/accent-picker/index.d.ts +2 -0
- package/dist/components/composites/accent-picker/index.d.ts.map +1 -0
- package/dist/components/composites/accent-picker/index.js +2 -0
- package/dist/components/composites/icon-picker/icon-picker.d.ts +37 -0
- package/dist/components/composites/icon-picker/icon-picker.d.ts.map +1 -0
- package/dist/components/composites/icon-picker/icon-picker.js +187 -0
- package/dist/components/composites/icon-picker/icon-picker.js.map +1 -0
- package/dist/components/composites/icon-picker/image-data-url.d.ts +42 -0
- package/dist/components/composites/icon-picker/image-data-url.d.ts.map +1 -0
- package/dist/components/composites/icon-picker/image-data-url.js +90 -0
- package/dist/components/composites/icon-picker/image-data-url.js.map +1 -0
- package/dist/components/composites/icon-picker/index.d.ts +4 -0
- package/dist/components/composites/icon-picker/index.d.ts.map +1 -0
- package/dist/components/composites/icon-picker/index.js +4 -0
- package/dist/components/composites/icon-picker/shell-icons.d.ts +264 -0
- package/dist/components/composites/icon-picker/shell-icons.d.ts.map +1 -0
- package/dist/components/composites/icon-picker/shell-icons.js +334 -0
- package/dist/components/composites/icon-picker/shell-icons.js.map +1 -0
- package/dist/components/composites/terminal/terminal.js +2 -2
- package/dist/components/composites/terminal/terminal.js.map +1 -1
- package/dist/components/composites/workspace-switcher/index.d.ts +2 -0
- package/dist/components/composites/workspace-switcher/index.d.ts.map +1 -0
- package/dist/components/composites/workspace-switcher/index.js +2 -0
- package/dist/components/composites/workspace-switcher/workspace-switcher.d.ts +44 -0
- package/dist/components/composites/workspace-switcher/workspace-switcher.d.ts.map +1 -0
- package/dist/components/composites/workspace-switcher/workspace-switcher.js +157 -0
- package/dist/components/composites/workspace-switcher/workspace-switcher.js.map +1 -0
- package/dist/components/layout/shell-rail/index.d.ts +2 -0
- package/dist/components/layout/shell-rail/index.d.ts.map +1 -0
- package/dist/components/layout/shell-rail/index.js +2 -0
- package/dist/components/layout/shell-rail/shell-rail.d.ts +36 -0
- package/dist/components/layout/shell-rail/shell-rail.d.ts.map +1 -0
- package/dist/components/layout/shell-rail/shell-rail.js +124 -0
- package/dist/components/layout/shell-rail/shell-rail.js.map +1 -0
- package/dist/components/layout/shell-top-bar/index.d.ts +2 -0
- package/dist/components/layout/shell-top-bar/index.d.ts.map +1 -0
- package/dist/components/layout/shell-top-bar/index.js +2 -0
- package/dist/components/layout/shell-top-bar/shell-top-bar.d.ts +68 -0
- package/dist/components/layout/shell-top-bar/shell-top-bar.d.ts.map +1 -0
- package/dist/components/layout/shell-top-bar/shell-top-bar.js +135 -0
- package/dist/components/layout/shell-top-bar/shell-top-bar.js.map +1 -0
- package/dist/components/layout/top-bar/top-bar.d.ts +21 -6
- package/dist/components/layout/top-bar/top-bar.d.ts.map +1 -1
- package/dist/components/layout/top-bar/top-bar.js +2 -2
- package/dist/components/layout/top-bar/top-bar.js.map +1 -1
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -1
- package/dist/r/accent-picker.json +25 -0
- package/dist/r/icon-picker.json +36 -0
- package/dist/r/registry.json +137 -0
- package/dist/r/shell-rail.json +27 -0
- package/dist/r/shell-top-bar.json +23 -0
- package/dist/r/src/components/composites/accent-picker/accent-picker.tsx +85 -0
- package/dist/r/src/components/composites/accent-picker/index.ts +1 -0
- package/dist/r/src/components/composites/icon-picker/icon-picker.tsx +184 -0
- package/dist/r/src/components/composites/icon-picker/image-data-url.ts +107 -0
- package/dist/r/src/components/composites/icon-picker/index.ts +20 -0
- package/dist/r/src/components/composites/icon-picker/shell-icons.ts +166 -0
- package/dist/r/src/components/composites/workspace-switcher/index.ts +5 -0
- package/dist/r/src/components/composites/workspace-switcher/workspace-switcher.tsx +128 -0
- package/dist/r/src/components/layout/shell-rail/index.ts +1 -0
- package/dist/r/src/components/layout/shell-rail/shell-rail.tsx +120 -0
- package/dist/r/src/components/layout/shell-top-bar/index.ts +1 -0
- package/dist/r/src/components/layout/shell-top-bar/shell-top-bar.tsx +185 -0
- package/dist/r/src/components/layout/top-bar/top-bar.tsx +27 -8
- package/dist/r/workspace-switcher.json +26 -0
- package/package.json +1 -1
- package/registry.json +137 -0
- package/src/components/composites/accent-picker/accent-picker.tsx +85 -0
- package/src/components/composites/accent-picker/index.ts +1 -0
- package/src/components/composites/icon-picker/icon-picker.tsx +184 -0
- package/src/components/composites/icon-picker/image-data-url.ts +107 -0
- package/src/components/composites/icon-picker/index.ts +20 -0
- package/src/components/composites/icon-picker/shell-icons.ts +166 -0
- package/src/components/composites/workspace-switcher/index.ts +5 -0
- package/src/components/composites/workspace-switcher/workspace-switcher.tsx +128 -0
- package/src/components/layout/shell-rail/index.ts +1 -0
- package/src/components/layout/shell-rail/shell-rail.tsx +120 -0
- package/src/components/layout/shell-top-bar/index.ts +1 -0
- package/src/components/layout/shell-top-bar/shell-top-bar.tsx +185 -0
- package/src/components/layout/top-bar/top-bar.tsx +27 -8
- package/src/index.ts +5 -0
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Anchor,
|
|
3
|
+
Bot,
|
|
4
|
+
Box,
|
|
5
|
+
BookOpen,
|
|
6
|
+
Brain,
|
|
7
|
+
Briefcase,
|
|
8
|
+
Building2,
|
|
9
|
+
Camera,
|
|
10
|
+
ChartColumn,
|
|
11
|
+
Cloud,
|
|
12
|
+
Code,
|
|
13
|
+
Coffee,
|
|
14
|
+
Compass,
|
|
15
|
+
Cpu,
|
|
16
|
+
Database,
|
|
17
|
+
Dumbbell,
|
|
18
|
+
Flag,
|
|
19
|
+
Flame,
|
|
20
|
+
FlaskConical,
|
|
21
|
+
Folder,
|
|
22
|
+
FolderGit2,
|
|
23
|
+
Gamepad2,
|
|
24
|
+
Gem,
|
|
25
|
+
Globe,
|
|
26
|
+
GraduationCap,
|
|
27
|
+
Hammer,
|
|
28
|
+
Heart,
|
|
29
|
+
House,
|
|
30
|
+
Key,
|
|
31
|
+
Layers,
|
|
32
|
+
Leaf,
|
|
33
|
+
Lightbulb,
|
|
34
|
+
type LucideIcon,
|
|
35
|
+
Map as MapIcon,
|
|
36
|
+
Megaphone,
|
|
37
|
+
MessagesSquare,
|
|
38
|
+
Moon,
|
|
39
|
+
Mountain,
|
|
40
|
+
Music,
|
|
41
|
+
Newspaper,
|
|
42
|
+
Package,
|
|
43
|
+
Palette,
|
|
44
|
+
Plane,
|
|
45
|
+
Puzzle,
|
|
46
|
+
Rocket,
|
|
47
|
+
Server,
|
|
48
|
+
Shapes,
|
|
49
|
+
Shield,
|
|
50
|
+
ShoppingCart,
|
|
51
|
+
Sparkles,
|
|
52
|
+
Star,
|
|
53
|
+
Sun,
|
|
54
|
+
Target,
|
|
55
|
+
Terminal,
|
|
56
|
+
Trophy,
|
|
57
|
+
Users,
|
|
58
|
+
Utensils,
|
|
59
|
+
Wallet,
|
|
60
|
+
Wrench,
|
|
61
|
+
Zap,
|
|
62
|
+
} from 'lucide-solid'
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The icons a workspace or a project may choose for itself.
|
|
66
|
+
*
|
|
67
|
+
* A curated set rather than the whole Lucide catalogue, for two reasons the
|
|
68
|
+
* picker depends on: the keys are persisted, so each one is a promise that the
|
|
69
|
+
* glyph keeps existing, and every icon here is imported by name so a consumer's
|
|
70
|
+
* bundle pays for these few dozen glyphs and not for the library.
|
|
71
|
+
*
|
|
72
|
+
* `home`, `box` and `folder` lead the list because they are the defaults the
|
|
73
|
+
* apps already use: Home workspaces carry `home`, other workspaces `box` and
|
|
74
|
+
* projects `folder`. Keys are lowercase kebab-case so they are valid project
|
|
75
|
+
* `iconKey`s as well as workspace logo values.
|
|
76
|
+
*/
|
|
77
|
+
export const shellIconEntries = [
|
|
78
|
+
{ key: 'home', label: 'Home', icon: House },
|
|
79
|
+
{ key: 'box', label: 'Box', icon: Box },
|
|
80
|
+
{ key: 'folder', label: 'Folder', icon: Folder },
|
|
81
|
+
{ key: 'git-folder', label: 'Repository', icon: FolderGit2 },
|
|
82
|
+
{ key: 'briefcase', label: 'Briefcase', icon: Briefcase },
|
|
83
|
+
{ key: 'building', label: 'Building', icon: Building2 },
|
|
84
|
+
{ key: 'users', label: 'Team', icon: Users },
|
|
85
|
+
{ key: 'rocket', label: 'Rocket', icon: Rocket },
|
|
86
|
+
{ key: 'star', label: 'Star', icon: Star },
|
|
87
|
+
{ key: 'heart', label: 'Heart', icon: Heart },
|
|
88
|
+
{ key: 'code', label: 'Code', icon: Code },
|
|
89
|
+
{ key: 'terminal', label: 'Terminal', icon: Terminal },
|
|
90
|
+
{ key: 'cpu', label: 'Chip', icon: Cpu },
|
|
91
|
+
{ key: 'server', label: 'Server', icon: Server },
|
|
92
|
+
{ key: 'database', label: 'Database', icon: Database },
|
|
93
|
+
{ key: 'cloud', label: 'Cloud', icon: Cloud },
|
|
94
|
+
{ key: 'globe', label: 'Globe', icon: Globe },
|
|
95
|
+
{ key: 'bot', label: 'Agent', icon: Bot },
|
|
96
|
+
{ key: 'brain', label: 'Brain', icon: Brain },
|
|
97
|
+
{ key: 'sparkles', label: 'Sparkles', icon: Sparkles },
|
|
98
|
+
{ key: 'lightbulb', label: 'Idea', icon: Lightbulb },
|
|
99
|
+
{ key: 'flask', label: 'Lab', icon: FlaskConical },
|
|
100
|
+
{ key: 'book', label: 'Book', icon: BookOpen },
|
|
101
|
+
{ key: 'graduation', label: 'Study', icon: GraduationCap },
|
|
102
|
+
{ key: 'newspaper', label: 'News', icon: Newspaper },
|
|
103
|
+
{ key: 'megaphone', label: 'Marketing', icon: Megaphone },
|
|
104
|
+
{ key: 'chart', label: 'Chart', icon: ChartColumn },
|
|
105
|
+
{ key: 'wallet', label: 'Finance', icon: Wallet },
|
|
106
|
+
{ key: 'cart', label: 'Shop', icon: ShoppingCart },
|
|
107
|
+
{ key: 'package', label: 'Package', icon: Package },
|
|
108
|
+
{ key: 'layers', label: 'Layers', icon: Layers },
|
|
109
|
+
{ key: 'puzzle', label: 'Puzzle', icon: Puzzle },
|
|
110
|
+
{ key: 'shield', label: 'Security', icon: Shield },
|
|
111
|
+
{ key: 'key', label: 'Key', icon: Key },
|
|
112
|
+
{ key: 'wrench', label: 'Tools', icon: Wrench },
|
|
113
|
+
{ key: 'hammer', label: 'Build', icon: Hammer },
|
|
114
|
+
{ key: 'zap', label: 'Bolt', icon: Zap },
|
|
115
|
+
{ key: 'flame', label: 'Flame', icon: Flame },
|
|
116
|
+
{ key: 'target', label: 'Target', icon: Target },
|
|
117
|
+
{ key: 'trophy', label: 'Trophy', icon: Trophy },
|
|
118
|
+
{ key: 'flag', label: 'Flag', icon: Flag },
|
|
119
|
+
{ key: 'compass', label: 'Compass', icon: Compass },
|
|
120
|
+
{ key: 'map', label: 'Map', icon: MapIcon },
|
|
121
|
+
{ key: 'plane', label: 'Travel', icon: Plane },
|
|
122
|
+
{ key: 'anchor', label: 'Anchor', icon: Anchor },
|
|
123
|
+
{ key: 'mountain', label: 'Mountain', icon: Mountain },
|
|
124
|
+
{ key: 'leaf', label: 'Garden', icon: Leaf },
|
|
125
|
+
{ key: 'sun', label: 'Sun', icon: Sun },
|
|
126
|
+
{ key: 'moon', label: 'Moon', icon: Moon },
|
|
127
|
+
{ key: 'palette', label: 'Design', icon: Palette },
|
|
128
|
+
{ key: 'camera', label: 'Camera', icon: Camera },
|
|
129
|
+
{ key: 'music', label: 'Music', icon: Music },
|
|
130
|
+
{ key: 'gamepad', label: 'Games', icon: Gamepad2 },
|
|
131
|
+
{ key: 'coffee', label: 'Coffee', icon: Coffee },
|
|
132
|
+
{ key: 'utensils', label: 'Kitchen', icon: Utensils },
|
|
133
|
+
{ key: 'dumbbell', label: 'Gym', icon: Dumbbell },
|
|
134
|
+
{ key: 'gem', label: 'Gem', icon: Gem },
|
|
135
|
+
{ key: 'messages', label: 'Chat', icon: MessagesSquare },
|
|
136
|
+
{ key: 'shapes', label: 'Shapes', icon: Shapes },
|
|
137
|
+
] as const satisfies readonly Readonly<{ key: string; label: string; icon: LucideIcon }>[]
|
|
138
|
+
|
|
139
|
+
export type ShellIconKey = (typeof shellIconEntries)[number]['key']
|
|
140
|
+
|
|
141
|
+
const shellIconByKey: ReadonlyMap<string, LucideIcon> = new Map(
|
|
142
|
+
shellIconEntries.map((entry) => [entry.key, entry.icon])
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
/** Whether a stored string names an icon in the curated set. */
|
|
146
|
+
export function isShellIconKey(value: string): value is ShellIconKey {
|
|
147
|
+
return shellIconByKey.has(value)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The glyph for a stored key, or `fallback` when the key is not in the set.
|
|
152
|
+
* Callers pass the fallback their surface wants (a workspace falls back to
|
|
153
|
+
* `box`, a project to `folder`), so an unknown key degrades to the default
|
|
154
|
+
* rather than to an empty tile.
|
|
155
|
+
*/
|
|
156
|
+
export function resolveShellIcon(
|
|
157
|
+
key: string | null | undefined,
|
|
158
|
+
fallback: ShellIconKey = 'folder'
|
|
159
|
+
): LucideIcon {
|
|
160
|
+
return (key ? shellIconByKey.get(key) : undefined) ?? shellIconByKey.get(fallback)!
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** The picker's accessible name for a key. */
|
|
164
|
+
export function shellIconLabel(key: string): string {
|
|
165
|
+
return shellIconEntries.find((entry) => entry.key === key)?.label ?? key
|
|
166
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { ChevronDown, Plus, Settings } from 'lucide-solid'
|
|
2
|
+
import type { JSX } from 'solid-js'
|
|
3
|
+
import { For, Show, createSignal } from 'solid-js'
|
|
4
|
+
import { cn } from '#lib/utils'
|
|
5
|
+
import {
|
|
6
|
+
DropdownMenu,
|
|
7
|
+
DropdownMenuContent,
|
|
8
|
+
DropdownMenuGroup,
|
|
9
|
+
DropdownMenuItem,
|
|
10
|
+
DropdownMenuLabel,
|
|
11
|
+
DropdownMenuRadioGroup,
|
|
12
|
+
DropdownMenuRadioItem,
|
|
13
|
+
DropdownMenuSeparator,
|
|
14
|
+
DropdownMenuTrigger,
|
|
15
|
+
} from '../../ui/dropdown-menu'
|
|
16
|
+
import { ActionButton } from '../action-button'
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* WorkspaceSwitcher.
|
|
20
|
+
*
|
|
21
|
+
* The workspace picker that sits in the top bar beside the sidebar toggle. One
|
|
22
|
+
* ghost button shows the current workspace's mark and name; pressing it opens a
|
|
23
|
+
* radio list of the others. Choosing one only changes what the window is
|
|
24
|
+
* looking at: the switcher reports the id and the host decides what that means,
|
|
25
|
+
* which is why it carries no status of its own and never closes, restarts or
|
|
26
|
+
* moves anything.
|
|
27
|
+
*
|
|
28
|
+
* It is deliberately the simple interaction (a single menu, not a nested
|
|
29
|
+
* accordion): the sidebar below it already shows what is inside the chosen
|
|
30
|
+
* workspace, so the switcher's only job is to name the workspace and let the
|
|
31
|
+
* user change it.
|
|
32
|
+
*/
|
|
33
|
+
export type WorkspaceSwitcherItem = {
|
|
34
|
+
id: string
|
|
35
|
+
name: string
|
|
36
|
+
/** The workspace's mark, drawn in the trigger and its row. Decorative. */
|
|
37
|
+
mark: JSX.Element
|
|
38
|
+
/** A quiet trailing readout in the row, e.g. an unread count. Optional. */
|
|
39
|
+
detail?: JSX.Element
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type WorkspaceSwitcherProps = {
|
|
43
|
+
workspaces: readonly WorkspaceSwitcherItem[]
|
|
44
|
+
/** The current workspace's id. */
|
|
45
|
+
value: string | undefined
|
|
46
|
+
onValueChange(id: string): void
|
|
47
|
+
/** Offered at the foot of the menu when present. */
|
|
48
|
+
onCreate?(): void
|
|
49
|
+
createLabel?: string
|
|
50
|
+
/** Offered beside the create action when present: open the current workspace's settings. */
|
|
51
|
+
onManage?(): void
|
|
52
|
+
manageLabel?: string
|
|
53
|
+
/** The menu's heading; also the trigger's accessible-name prefix. */
|
|
54
|
+
label?: string
|
|
55
|
+
/** Shown while no workspace is current. */
|
|
56
|
+
placeholder?: string
|
|
57
|
+
disabled?: boolean
|
|
58
|
+
class?: string
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function WorkspaceSwitcher(props: WorkspaceSwitcherProps) {
|
|
62
|
+
const [open, setOpen] = createSignal(false)
|
|
63
|
+
const current = () => props.workspaces.find((item) => item.id === props.value)
|
|
64
|
+
const label = () => props.label ?? 'Workspaces'
|
|
65
|
+
|
|
66
|
+
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>
|
|
117
|
+
</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>
|
|
127
|
+
)
|
|
128
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { ShellRail, type ShellRailProps } from './shell-rail'
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { Search } from 'lucide-solid'
|
|
2
|
+
import type { ComponentProps, JSX } from 'solid-js'
|
|
3
|
+
import { children, For, Show, splitProps } from 'solid-js'
|
|
4
|
+
import { cn } from '#lib/utils'
|
|
5
|
+
import { Kbd, KbdGroup } from '../../ui/kbd'
|
|
6
|
+
import { Separator } from '../../ui/separator'
|
|
7
|
+
import {
|
|
8
|
+
SideRail,
|
|
9
|
+
SideRailContent,
|
|
10
|
+
SideRailFooter,
|
|
11
|
+
SideRailHeader,
|
|
12
|
+
SideRailItem,
|
|
13
|
+
} from '../side-rail'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* ShellRail.
|
|
17
|
+
*
|
|
18
|
+
* The side rail both applications put at the window's leading edge: always the
|
|
19
|
+
* collapsed icon column, with the app's mark at the top, a search entry point
|
|
20
|
+
* under it, the app's destinations in the middle and a footer for account and
|
|
21
|
+
* settings.
|
|
22
|
+
*
|
|
23
|
+
* The rail is never the thing the sidebar toggle collapses. The toggle belongs
|
|
24
|
+
* to the sidebar beside the rail, which is why this component takes no
|
|
25
|
+
* `collapsed` prop: a rail that could be expanded would be a second sidebar,
|
|
26
|
+
* and the two apps would drift on which of them the toggle meant.
|
|
27
|
+
*
|
|
28
|
+
* Destinations are the caller's: pass `SideRailItem`s (or a `SideRailSection`)
|
|
29
|
+
* as children. The mark, the search row and the footer are slots with the
|
|
30
|
+
* geometry decided here, so the rail's rhythm cannot differ between apps.
|
|
31
|
+
*/
|
|
32
|
+
export type ShellRailProps = Omit<ComponentProps<'nav'>, 'children'> & {
|
|
33
|
+
/** The app's mark. Decorative: the rail names itself through `aria-label`. */
|
|
34
|
+
mark: JSX.Element
|
|
35
|
+
/** Opens search. Omit to leave the search row out. */
|
|
36
|
+
onSearch?: () => void
|
|
37
|
+
/** The search row's accessible name. */
|
|
38
|
+
searchLabel?: string
|
|
39
|
+
/** The chord as it is announced, e.g. `Meta+K Control+K`. */
|
|
40
|
+
searchKeyshortcuts?: string
|
|
41
|
+
/** The cap labels drawn under the icon, in press order, e.g. `['⌘', 'K']`. */
|
|
42
|
+
searchKeys?: readonly string[]
|
|
43
|
+
/** Destinations between the search row and the footer. */
|
|
44
|
+
children?: JSX.Element
|
|
45
|
+
/** Account, plugins, settings. Pinned to the rail's foot. */
|
|
46
|
+
footer?: JSX.Element
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function ShellRail(props: ShellRailProps) {
|
|
50
|
+
const [local, rest] = splitProps(props, [
|
|
51
|
+
'class',
|
|
52
|
+
'mark',
|
|
53
|
+
'onSearch',
|
|
54
|
+
'searchLabel',
|
|
55
|
+
'searchKeyshortcuts',
|
|
56
|
+
'searchKeys',
|
|
57
|
+
'children',
|
|
58
|
+
'footer',
|
|
59
|
+
'aria-label',
|
|
60
|
+
])
|
|
61
|
+
|
|
62
|
+
// Resolve the footer once: reading a JSX prop twice builds two copies.
|
|
63
|
+
const footer = children(() => local.footer)
|
|
64
|
+
const content = children(() => local.children)
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<SideRail
|
|
68
|
+
collapsed
|
|
69
|
+
data-slot="shell-rail"
|
|
70
|
+
aria-label={local['aria-label'] ?? 'Global navigation'}
|
|
71
|
+
class={cn('relative z-(--z-sticky) min-w-0', local.class)}
|
|
72
|
+
{...rest}
|
|
73
|
+
>
|
|
74
|
+
<SideRailHeader data-slot="shell-rail-header" class="h-auto justify-center px-0 pt-1">
|
|
75
|
+
<span
|
|
76
|
+
data-slot="shell-rail-mark"
|
|
77
|
+
aria-hidden="true"
|
|
78
|
+
class="flex h-rail-item items-center justify-center"
|
|
79
|
+
>
|
|
80
|
+
{local.mark}
|
|
81
|
+
</span>
|
|
82
|
+
</SideRailHeader>
|
|
83
|
+
|
|
84
|
+
<SideRailContent data-slot="shell-rail-content" class="pt-0.5">
|
|
85
|
+
<Show when={local.onSearch}>
|
|
86
|
+
<div data-slot="shell-rail-search" class="flex min-w-0 flex-col gap-0.5">
|
|
87
|
+
<SideRailItem
|
|
88
|
+
as="button"
|
|
89
|
+
type="button"
|
|
90
|
+
label={local.searchLabel ?? 'Search'}
|
|
91
|
+
aria-label={local.searchLabel ?? 'Search'}
|
|
92
|
+
keyshortcuts={local.searchKeyshortcuts}
|
|
93
|
+
shortcut={local.searchKeys?.join('')}
|
|
94
|
+
onClick={() => local.onSearch?.()}
|
|
95
|
+
>
|
|
96
|
+
<Search aria-hidden="true" />
|
|
97
|
+
</SideRailItem>
|
|
98
|
+
{/* The chord is drawn as key caps under the icon: the one row that
|
|
99
|
+
advertises a shortcut. The accessible chord stays on the row. */}
|
|
100
|
+
<Show when={local.searchKeys?.length}>
|
|
101
|
+
<KbdGroup
|
|
102
|
+
size="compact"
|
|
103
|
+
class="justify-center pb-1"
|
|
104
|
+
data-slot="shell-rail-search-keys"
|
|
105
|
+
>
|
|
106
|
+
<For each={local.searchKeys}>{(key) => <Kbd size="compact">{key}</Kbd>}</For>
|
|
107
|
+
</KbdGroup>
|
|
108
|
+
</Show>
|
|
109
|
+
</div>
|
|
110
|
+
<Separator class="my-1" />
|
|
111
|
+
</Show>
|
|
112
|
+
{content()}
|
|
113
|
+
</SideRailContent>
|
|
114
|
+
|
|
115
|
+
<Show when={footer()}>
|
|
116
|
+
<SideRailFooter data-slot="shell-rail-footer">{footer()}</SideRailFooter>
|
|
117
|
+
</Show>
|
|
118
|
+
</SideRail>
|
|
119
|
+
)
|
|
120
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { ShellTopBar, type ShellTopBarProps } from './shell-top-bar'
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import type { ComponentProps, JSX } from 'solid-js'
|
|
2
|
+
import { children, Show, splitProps } from 'solid-js'
|
|
3
|
+
import { cn } from '#lib/utils'
|
|
4
|
+
import { Separator } from '../../ui/separator'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* ShellTopBar.
|
|
8
|
+
*
|
|
9
|
+
* The window's top bar as both applications draw it. It has two halves with a
|
|
10
|
+
* vertical divider between them:
|
|
11
|
+
*
|
|
12
|
+
* - **Leading**: history arrows, the sidebar toggle and the workspace
|
|
13
|
+
* switcher, in that order. This is the part that belongs to the window.
|
|
14
|
+
* - **Content**: breadcrumbs, search and the view's actions. This is the part
|
|
15
|
+
* that belongs to the view, and it always sits to the right of the divider.
|
|
16
|
+
*
|
|
17
|
+
* ## Why the divider tracks the sidebar
|
|
18
|
+
*
|
|
19
|
+
* With the sidebar open the divider lands on the sidebar's trailing edge, so the
|
|
20
|
+
* bar reads as the rail and sidebar's header on one side and the content's
|
|
21
|
+
* header on the other. The bar spans the rail and the sidebar (they sit under
|
|
22
|
+
* it), so the leading half is as wide as `--rail-width` plus the sidebar's width
|
|
23
|
+
* less the bar's own start padding. The host publishes the width as
|
|
24
|
+
* `--shell-sidebar-width` on an ancestor; the fallback of 17rem matches the
|
|
25
|
+
* default sidebar. Below the 48rem breakpoint the sidebar is a drawer and the
|
|
26
|
+
* leading half is only as wide as its controls.
|
|
27
|
+
*
|
|
28
|
+
* ## Narrow windows
|
|
29
|
+
*
|
|
30
|
+
* The bar is its own container. Below 34rem the search drops to a row of its
|
|
31
|
+
* own so the leading controls, the breadcrumb and the actions keep their room;
|
|
32
|
+
* the breadcrumb is the first thing to truncate.
|
|
33
|
+
*
|
|
34
|
+
* On a frameless desktop window the bar is the drag region (`draggable`) and
|
|
35
|
+
* every control opts back out; `macosInset` and `windowsInset` reserve the
|
|
36
|
+
* native window controls.
|
|
37
|
+
*/
|
|
38
|
+
export type ShellTopBarProps = Omit<ComponentProps<'header'>, 'children'> & {
|
|
39
|
+
/** History arrows. Rendered first, left of the sidebar toggle. */
|
|
40
|
+
navigation?: JSX.Element
|
|
41
|
+
/** The sidebar toggle button. */
|
|
42
|
+
sidebarToggle?: JSX.Element
|
|
43
|
+
/** The workspace switcher. */
|
|
44
|
+
workspace?: JSX.Element
|
|
45
|
+
/**
|
|
46
|
+
* Whether the sidebar is open at wide widths. Open, the divider aligns with
|
|
47
|
+
* the sidebar's edge; closed, the leading half hugs its controls.
|
|
48
|
+
*/
|
|
49
|
+
sidebarOpen?: boolean
|
|
50
|
+
/**
|
|
51
|
+
* The open sidebar's width in pixels. Published as `--shell-sidebar-width` on
|
|
52
|
+
* the bar so the divider tracks a resizable sidebar; leave it unset to use an
|
|
53
|
+
* ancestor's variable or the 17rem default.
|
|
54
|
+
*/
|
|
55
|
+
sidebarWidth?: number
|
|
56
|
+
/** Breadcrumbs or the view's title. Takes the free space and truncates first. */
|
|
57
|
+
breadcrumb?: JSX.Element
|
|
58
|
+
/** The view's search. */
|
|
59
|
+
search?: JSX.Element
|
|
60
|
+
/** The view's actions, at the trailing edge. */
|
|
61
|
+
actions?: JSX.Element
|
|
62
|
+
/** Make the bar a drag region, for a frameless window. */
|
|
63
|
+
draggable?: boolean
|
|
64
|
+
/** Reserve space for the macOS traffic lights. */
|
|
65
|
+
macosInset?: boolean
|
|
66
|
+
/** Reserve space for the Windows caption buttons. */
|
|
67
|
+
windowsInset?: boolean
|
|
68
|
+
/** Blur the content scrolling under the bar. */
|
|
69
|
+
glass?: boolean
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function ShellTopBar(props: ShellTopBarProps) {
|
|
73
|
+
const [local, rest] = splitProps(props, [
|
|
74
|
+
'class',
|
|
75
|
+
'navigation',
|
|
76
|
+
'sidebarToggle',
|
|
77
|
+
'workspace',
|
|
78
|
+
'sidebarOpen',
|
|
79
|
+
'sidebarWidth',
|
|
80
|
+
'breadcrumb',
|
|
81
|
+
'search',
|
|
82
|
+
'actions',
|
|
83
|
+
'draggable',
|
|
84
|
+
'macosInset',
|
|
85
|
+
'windowsInset',
|
|
86
|
+
'glass',
|
|
87
|
+
])
|
|
88
|
+
|
|
89
|
+
// Resolve each slot once: a JSX prop is a getter that builds its elements on
|
|
90
|
+
// every read, so testing it with `Show` and then rendering it would build
|
|
91
|
+
// (and run the refs of) two copies.
|
|
92
|
+
const navigation = children(() => local.navigation)
|
|
93
|
+
const sidebarToggle = children(() => local.sidebarToggle)
|
|
94
|
+
const workspace = children(() => local.workspace)
|
|
95
|
+
const breadcrumb = children(() => local.breadcrumb)
|
|
96
|
+
const search = children(() => local.search)
|
|
97
|
+
const actions = children(() => local.actions)
|
|
98
|
+
|
|
99
|
+
return (
|
|
100
|
+
<header
|
|
101
|
+
data-slot="shell-top-bar"
|
|
102
|
+
class={cn(
|
|
103
|
+
'@container/shell-top-bar relative z-(--z-sticky) flex min-h-topbar shrink-0 flex-wrap items-center border-b border-border bg-card text-card-foreground',
|
|
104
|
+
local.glass && 'glass-panel',
|
|
105
|
+
local.draggable && 'window-drag',
|
|
106
|
+
/* Each edge gets exactly one inline-padding utility, for the reason the
|
|
107
|
+
shared TopBar documents: two would tie-break by build order. The inset
|
|
108
|
+
is also published as a variable so the leading half can subtract it. */
|
|
109
|
+
local.macosInset
|
|
110
|
+
? 'window-inset-macos [--shell-top-bar-inset:84px]'
|
|
111
|
+
: 'ps-3 [--shell-top-bar-inset:0.75rem]',
|
|
112
|
+
local.windowsInset ? 'window-inset-windows' : 'pe-3',
|
|
113
|
+
local.class
|
|
114
|
+
)}
|
|
115
|
+
style={
|
|
116
|
+
local.sidebarWidth === undefined
|
|
117
|
+
? undefined
|
|
118
|
+
: { '--shell-sidebar-width': `${local.sidebarWidth}px` }
|
|
119
|
+
}
|
|
120
|
+
{...rest}
|
|
121
|
+
>
|
|
122
|
+
<div
|
|
123
|
+
data-slot="shell-top-bar-leading"
|
|
124
|
+
data-sidebar-open={local.sidebarOpen ? 'true' : 'false'}
|
|
125
|
+
class={cn(
|
|
126
|
+
'window-no-drag flex h-topbar min-w-0 shrink-0 items-center gap-2 overflow-x-auto overflow-y-hidden pe-3',
|
|
127
|
+
/* Wide and open: span the rail and the sidebar so the divider lands on
|
|
128
|
+
the sidebar's edge. The sidebar's own border is the 1px the divider
|
|
129
|
+
overlaps, so no extra allowance is needed. */
|
|
130
|
+
local.sidebarOpen &&
|
|
131
|
+
'md:w-[calc(var(--rail-width)+var(--shell-sidebar-width,17rem)-var(--shell-top-bar-inset))]'
|
|
132
|
+
)}
|
|
133
|
+
>
|
|
134
|
+
<Show when={navigation()}>
|
|
135
|
+
<div data-slot="shell-top-bar-navigation" class="flex shrink-0 items-center gap-2">
|
|
136
|
+
{navigation()}
|
|
137
|
+
</div>
|
|
138
|
+
</Show>
|
|
139
|
+
<Show when={sidebarToggle()}>
|
|
140
|
+
<div data-slot="shell-top-bar-toggle" class="flex shrink-0 items-center">
|
|
141
|
+
{sidebarToggle()}
|
|
142
|
+
</div>
|
|
143
|
+
</Show>
|
|
144
|
+
<Show when={workspace()}>
|
|
145
|
+
<div data-slot="shell-top-bar-workspace" class="flex min-w-0 items-center">
|
|
146
|
+
{workspace()}
|
|
147
|
+
</div>
|
|
148
|
+
</Show>
|
|
149
|
+
</div>
|
|
150
|
+
<Separator
|
|
151
|
+
orientation="vertical"
|
|
152
|
+
data-slot="shell-top-bar-divider"
|
|
153
|
+
aria-hidden="true"
|
|
154
|
+
class="h-5"
|
|
155
|
+
/>
|
|
156
|
+
<Show when={breadcrumb()}>
|
|
157
|
+
<div
|
|
158
|
+
data-slot="shell-top-bar-breadcrumb"
|
|
159
|
+
class="window-no-drag flex h-topbar min-w-0 flex-1 items-center truncate ps-3 text-sm [&_:is(a,button)]:window-no-drag"
|
|
160
|
+
>
|
|
161
|
+
{breadcrumb()}
|
|
162
|
+
</div>
|
|
163
|
+
</Show>
|
|
164
|
+
<Show when={!breadcrumb()}>
|
|
165
|
+
<div class="min-w-0 flex-1" aria-hidden="true" />
|
|
166
|
+
</Show>
|
|
167
|
+
<Show when={search()}>
|
|
168
|
+
<div
|
|
169
|
+
data-slot="shell-top-bar-search"
|
|
170
|
+
class="window-no-drag min-w-0 max-w-md shrink basis-72 px-3 @max-[34rem]/shell-top-bar:order-last @max-[34rem]/shell-top-bar:max-w-none @max-[34rem]/shell-top-bar:basis-full @max-[34rem]/shell-top-bar:pb-2"
|
|
171
|
+
>
|
|
172
|
+
{search()}
|
|
173
|
+
</div>
|
|
174
|
+
</Show>
|
|
175
|
+
<Show when={actions()}>
|
|
176
|
+
<div
|
|
177
|
+
data-slot="shell-top-bar-actions"
|
|
178
|
+
class="window-no-drag flex h-topbar min-w-0 shrink-0 items-center gap-2 overflow-x-auto overflow-y-hidden ps-1"
|
|
179
|
+
>
|
|
180
|
+
{actions()}
|
|
181
|
+
</div>
|
|
182
|
+
</Show>
|
|
183
|
+
</header>
|
|
184
|
+
)
|
|
185
|
+
}
|
|
@@ -14,9 +14,22 @@ import { cn } from '#lib/utils'
|
|
|
14
14
|
*
|
|
15
15
|
* The centre track is sized for a lone search field. A bar whose centre holds
|
|
16
16
|
* more than that, such as a search plus an action beside it, passes
|
|
17
|
-
* `centerWidth="wide"`: the centre then stays fluid below 1280px,
|
|
18
|
-
*
|
|
19
|
-
* group
|
|
17
|
+
* `centerWidth="wide"`: the centre then stays fluid below 1280px, with each
|
|
18
|
+
* side group sized to its content but capped between 30% and 40% of the row
|
|
19
|
+
* (a longer group scrolls instead): the cap is 30% on the narrowest rows and
|
|
20
|
+
* grows to 40% as the row widens, so full side groups never squeeze the
|
|
21
|
+
* centre's search out of it and enlarged text never pushes a short leading
|
|
22
|
+
* control such as a navigation toggle out of the row. Above 1280px it takes a
|
|
23
|
+
* wider fixed track.
|
|
24
|
+
*
|
|
25
|
+
* A wide bar also never squeezes its centre to nothing. When the row is
|
|
26
|
+
* narrower than 19rem (measured in rem, so enlarged text reaches it at a wider
|
|
27
|
+
* window) the centre moves to a second row of its own and the two side groups
|
|
28
|
+
* share the first, the trailing group taking the remainder beside the leading
|
|
29
|
+
* group's cap, so the leading and trailing controls and the whole centre each
|
|
30
|
+
* keep a full row instead of fighting for one. The bar grows from one row
|
|
31
|
+
* to two only then. The wide layout places the leading group, centre and
|
|
32
|
+
* trailing group as its first, second and third children.
|
|
20
33
|
*
|
|
21
34
|
* On a frameless desktop window this row is also the drag region, so it carries
|
|
22
35
|
* `window-drag` and the controls inside it opt back out. `macos-inset` and
|
|
@@ -35,9 +48,11 @@ export type TopBarProps = ComponentProps<'header'> & {
|
|
|
35
48
|
glass?: boolean
|
|
36
49
|
/**
|
|
37
50
|
* How wide the centre track is. `field` (default) suits a lone search field.
|
|
38
|
-
* `wide` suits a centre that also holds an action: fluid below 1280px
|
|
39
|
-
*
|
|
40
|
-
* clamp of 32vw up to 672px between equal
|
|
51
|
+
* `wide` suits a centre that also holds an action: fluid below 1280px between
|
|
52
|
+
* side groups sized to their content and capped between 30% and 40% of the
|
|
53
|
+
* row (a longer group scrolls), then a clamp of 32vw up to 672px between equal
|
|
54
|
+
* side tracks.
|
|
55
|
+
* Below a 19rem row the centre drops to a second row of its own.
|
|
41
56
|
*/
|
|
42
57
|
centerWidth?: 'field' | 'wide'
|
|
43
58
|
}
|
|
@@ -57,8 +72,12 @@ export function TopBar(props: TopBarProps) {
|
|
|
57
72
|
data-slot="top-bar"
|
|
58
73
|
class={cn(
|
|
59
74
|
'h-topbar relative z-(--z-sticky) grid shrink-0 grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center gap-3 border-b border-border bg-card text-card-foreground md:grid-cols-[minmax(0,1fr)_clamp(240px,22vw,480px)_minmax(0,1fr)]',
|
|
75
|
+
/* The bar is its own container so its children can move to a second
|
|
76
|
+
row by the row's width in rem, which enlarged text shrinks. The
|
|
77
|
+
first row keeps the bar's height (less the 1px bottom border); the
|
|
78
|
+
second only exists when the centre has dropped onto it. */
|
|
60
79
|
local.centerWidth === 'wide' &&
|
|
61
|
-
'grid-cols-[
|
|
80
|
+
'@container/top-bar h-auto grid-cols-[fit-content(max(30%,min(40%,calc(50%-3.75rem))))_minmax(0,1fr)_fit-content(max(30%,min(40%,calc(50%-3.75rem))))] grid-rows-[minmax(calc(var(--topbar-height)-1px),auto)] gap-x-2 gap-y-0 md:grid-cols-[fit-content(max(30%,min(40%,calc(50%-3.75rem))))_minmax(0,1fr)_fit-content(max(30%,min(40%,calc(50%-3.75rem))))] xl:grid-cols-[minmax(0,1fr)_clamp(240px,32vw,672px)_minmax(0,1fr)] [&>:nth-child(2)]:@max-[19rem]/top-bar:col-span-full [&>:nth-child(2)]:@max-[19rem]/top-bar:row-start-2 [&>:nth-child(2)]:@max-[19rem]/top-bar:pb-2 [&>:nth-child(3)]:@max-[19rem]/top-bar:col-span-2 [&>:nth-child(3)]:@max-[19rem]/top-bar:col-start-2 [&>:nth-child(3)]:@max-[19rem]/top-bar:row-start-1',
|
|
62
81
|
local.glass && 'glass-panel',
|
|
63
82
|
local.draggable && 'window-drag',
|
|
64
83
|
/* Each edge gets exactly one inline-padding utility. A window inset
|
|
@@ -93,7 +112,7 @@ export function TopBarSection(props: ComponentProps<'div'> & { align?: 'start' |
|
|
|
93
112
|
data-slot="top-bar-section"
|
|
94
113
|
class={cn(
|
|
95
114
|
'window-no-drag flex min-w-0 items-center gap-2 overflow-x-auto overflow-y-hidden',
|
|
96
|
-
local.align === 'end' && 'md:justify-end',
|
|
115
|
+
local.align === 'end' && 'md:justify-end-safe',
|
|
97
116
|
local.align !== 'end' && 'justify-start',
|
|
98
117
|
local.class
|
|
99
118
|
)}
|