@adea-ai/ui 0.142.4 → 0.144.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 +107 -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 +52 -0
- package/dist/components/composites/icon-picker/icon-picker.d.ts.map +1 -0
- package/dist/components/composites/icon-picker/icon-picker.js +265 -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/settings/settings-navigation.js +1 -1
- package/dist/components/composites/settings/settings-navigation.js.map +1 -1
- package/dist/components/composites/settings/settings.d.ts +8 -14
- package/dist/components/composites/settings/settings.d.ts.map +1 -1
- package/dist/components/composites/settings/settings.js +88 -37
- package/dist/components/composites/settings/settings.js.map +1 -1
- package/dist/components/composites/terminal/terminal.js +2 -2
- package/dist/components/composites/terminal/terminal.js.map +1 -1
- package/dist/components/composites/tree/tree.d.ts +6 -0
- package/dist/components/composites/tree/tree.d.ts.map +1 -1
- package/dist/components/composites/tree/tree.js +35 -14
- package/dist/components/composites/tree/tree.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/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 +37 -0
- package/dist/r/registry.json +141 -1
- package/dist/r/settings.json +3 -1
- 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 +106 -0
- package/dist/r/src/components/composites/accent-picker/index.ts +1 -0
- package/dist/r/src/components/composites/icon-picker/icon-picker.tsx +246 -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 +25 -0
- package/dist/r/src/components/composites/icon-picker/shell-icons.ts +166 -0
- package/dist/r/src/components/composites/settings/settings-navigation.tsx +1 -1
- package/dist/r/src/components/composites/settings/settings.tsx +40 -5
- package/dist/r/src/components/composites/tree/tree.tsx +25 -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/workspace-switcher.json +26 -0
- package/package.json +1 -1
- package/registry.json +141 -1
- package/src/components/composites/accent-picker/accent-picker.tsx +106 -0
- package/src/components/composites/accent-picker/index.ts +1 -0
- package/src/components/composites/icon-picker/icon-picker.tsx +246 -0
- package/src/components/composites/icon-picker/image-data-url.ts +107 -0
- package/src/components/composites/icon-picker/index.ts +25 -0
- package/src/components/composites/icon-picker/shell-icons.ts +166 -0
- package/src/components/composites/settings/settings-navigation.tsx +1 -1
- package/src/components/composites/settings/settings.tsx +40 -5
- package/src/components/composites/tree/tree.tsx +25 -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/index.ts +5 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turning an uploaded picture into the small square mark a workspace stores.
|
|
3
|
+
*
|
|
4
|
+
* The mark travels with the workspace record (it is shown in every list the
|
|
5
|
+
* workspace appears in), so it is resized to a fixed square and re-encoded
|
|
6
|
+
* before it leaves the browser rather than stored as the user's original file.
|
|
7
|
+
* Re-encoding also drops metadata and any embedded script an SVG could carry:
|
|
8
|
+
* only raster images are accepted and the result is always a `data:image/webp`
|
|
9
|
+
* or `data:image/png` URL.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** The edge, in pixels, of the stored square. 2x the largest tile the apps draw. */
|
|
13
|
+
export const MARK_IMAGE_SIZE = 128
|
|
14
|
+
/** The largest source file read, before resizing. */
|
|
15
|
+
export const MARK_IMAGE_MAX_SOURCE_BYTES = 8 * 1024 * 1024
|
|
16
|
+
/** The largest encoded result kept; a stored mark never exceeds this many characters. */
|
|
17
|
+
export const MARK_IMAGE_MAX_DATA_URL_LENGTH = 48 * 1024
|
|
18
|
+
|
|
19
|
+
const ACCEPTED_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif'])
|
|
20
|
+
|
|
21
|
+
export type MarkImageProblem = 'type' | 'size' | 'unreadable' | 'too-large'
|
|
22
|
+
|
|
23
|
+
/** Thrown by {@link markImageFromFile}; `problem` keys {@link markImageProblemCopy}. */
|
|
24
|
+
export class MarkImageError extends Error {
|
|
25
|
+
readonly problem: MarkImageProblem
|
|
26
|
+
constructor(problem: MarkImageProblem) {
|
|
27
|
+
super(problem)
|
|
28
|
+
this.name = 'MarkImageError'
|
|
29
|
+
this.problem = problem
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const markImageProblemCopy: Readonly<Record<MarkImageProblem, string>> = {
|
|
34
|
+
type: 'Choose a PNG, JPEG, WebP or GIF image.',
|
|
35
|
+
size: 'That image is larger than 8 MB. Choose a smaller one.',
|
|
36
|
+
unreadable: 'That image could not be read. Try a different file.',
|
|
37
|
+
'too-large': 'That image is too detailed to store as a mark. Try a simpler one.',
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Whether a stored value is a mark image this module could have produced. */
|
|
41
|
+
export function isMarkImageDataUrl(value: unknown): value is string {
|
|
42
|
+
return (
|
|
43
|
+
typeof value === 'string' &&
|
|
44
|
+
value.length <= MARK_IMAGE_MAX_DATA_URL_LENGTH &&
|
|
45
|
+
/^data:image\/(?:webp|png);base64,[A-Za-z0-9+/]+={0,2}$/.test(value)
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Pre-flight checks that need no decoding; `undefined` means the file may be read. */
|
|
50
|
+
export function checkMarkImageFile(
|
|
51
|
+
file: Readonly<{ type: string; size: number }>
|
|
52
|
+
): MarkImageProblem | undefined {
|
|
53
|
+
if (!ACCEPTED_TYPES.has(file.type)) return 'type'
|
|
54
|
+
if (file.size > MARK_IMAGE_MAX_SOURCE_BYTES) return 'size'
|
|
55
|
+
return undefined
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The source rectangle of the largest centred square, so the mark is a cover crop. */
|
|
59
|
+
export function centredSquare(
|
|
60
|
+
width: number,
|
|
61
|
+
height: number
|
|
62
|
+
): Readonly<{ x: number; y: number; side: number }> {
|
|
63
|
+
const side = Math.min(width, height)
|
|
64
|
+
return { x: Math.floor((width - side) / 2), y: Math.floor((height - side) / 2), side }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Resize an image file to the stored mark. Rejects with a {@link MarkImageError}
|
|
69
|
+
* so the caller can show `markImageProblemCopy[error.problem]`.
|
|
70
|
+
*/
|
|
71
|
+
export async function markImageFromFile(file: File): Promise<string> {
|
|
72
|
+
const problem = checkMarkImageFile(file)
|
|
73
|
+
if (problem) throw new MarkImageError(problem)
|
|
74
|
+
let bitmap: ImageBitmap
|
|
75
|
+
try {
|
|
76
|
+
bitmap = await createImageBitmap(file)
|
|
77
|
+
} catch {
|
|
78
|
+
throw new MarkImageError('unreadable')
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
const canvas = document.createElement('canvas')
|
|
82
|
+
canvas.width = MARK_IMAGE_SIZE
|
|
83
|
+
canvas.height = MARK_IMAGE_SIZE
|
|
84
|
+
const context = canvas.getContext('2d')
|
|
85
|
+
if (!context) throw new MarkImageError('unreadable')
|
|
86
|
+
const crop = centredSquare(bitmap.width, bitmap.height)
|
|
87
|
+
context.drawImage(
|
|
88
|
+
bitmap,
|
|
89
|
+
crop.x,
|
|
90
|
+
crop.y,
|
|
91
|
+
crop.side,
|
|
92
|
+
crop.side,
|
|
93
|
+
0,
|
|
94
|
+
0,
|
|
95
|
+
MARK_IMAGE_SIZE,
|
|
96
|
+
MARK_IMAGE_SIZE
|
|
97
|
+
)
|
|
98
|
+
// WebP where the browser can encode it; PNG is the always-available fallback.
|
|
99
|
+
for (const type of ['image/webp', 'image/png']) {
|
|
100
|
+
const url = canvas.toDataURL(type, 0.85)
|
|
101
|
+
if (url.startsWith(`data:${type}`) && isMarkImageDataUrl(url)) return url
|
|
102
|
+
}
|
|
103
|
+
throw new MarkImageError('too-large')
|
|
104
|
+
} finally {
|
|
105
|
+
bitmap.close()
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export {
|
|
2
|
+
IconPicker,
|
|
3
|
+
IconPickerDropdown,
|
|
4
|
+
type IconPickerDropdownProps,
|
|
5
|
+
type IconPickerProps,
|
|
6
|
+
} from './icon-picker'
|
|
7
|
+
export {
|
|
8
|
+
MARK_IMAGE_MAX_DATA_URL_LENGTH,
|
|
9
|
+
MARK_IMAGE_MAX_SOURCE_BYTES,
|
|
10
|
+
MARK_IMAGE_SIZE,
|
|
11
|
+
MarkImageError,
|
|
12
|
+
centredSquare,
|
|
13
|
+
checkMarkImageFile,
|
|
14
|
+
isMarkImageDataUrl,
|
|
15
|
+
markImageFromFile,
|
|
16
|
+
markImageProblemCopy,
|
|
17
|
+
type MarkImageProblem,
|
|
18
|
+
} from './image-data-url'
|
|
19
|
+
export {
|
|
20
|
+
isShellIconKey,
|
|
21
|
+
resolveShellIcon,
|
|
22
|
+
shellIconEntries,
|
|
23
|
+
shellIconLabel,
|
|
24
|
+
type ShellIconKey,
|
|
25
|
+
} from './shell-icons'
|
|
@@ -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
|
+
}
|
|
@@ -182,7 +182,7 @@ export function SettingsNavigation(props: SettingsNavigationProps) {
|
|
|
182
182
|
class={cn(
|
|
183
183
|
sidebarNavItemClass,
|
|
184
184
|
sidebarNavItemStateClass.idle,
|
|
185
|
-
'h-row-lg justify-start border-0 px-3 py-2 text-left data-[selected]:bg-primary-subtle
|
|
185
|
+
'h-row-lg justify-start border-0 px-3 py-2 text-left text-foreground data-[selected]:bg-primary-subtle',
|
|
186
186
|
strip() ? 'w-auto shrink-0' : 'w-full'
|
|
187
187
|
)}
|
|
188
188
|
>
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import { CircleHelp } from 'lucide-solid'
|
|
1
2
|
import type { ComponentProps, JSX } from 'solid-js'
|
|
2
3
|
import { createUniqueId, Show, splitProps } from 'solid-js'
|
|
3
4
|
import { createFormFieldContext, FormFieldContext } from '../../../lib/form-field'
|
|
4
5
|
import { cn } from '../../../lib/utils'
|
|
6
|
+
import { Tooltip, TooltipContent, TooltipTrigger } from '../../ui/tooltip'
|
|
5
7
|
import { Heading } from '../../ui/typography'
|
|
6
8
|
|
|
7
9
|
/**
|
|
@@ -18,10 +20,29 @@ import { Heading } from '../../ui/typography'
|
|
|
18
20
|
* the first control inside it. `SettingsField` is the visual stacked layout;
|
|
19
21
|
* use `FormField` when the control needs label, help, or validation wiring.
|
|
20
22
|
*/
|
|
23
|
+
function HelpTip(props: { label: string; help: string }) {
|
|
24
|
+
return (
|
|
25
|
+
<Tooltip>
|
|
26
|
+
<TooltipTrigger
|
|
27
|
+
as="button"
|
|
28
|
+
type="button"
|
|
29
|
+
data-slot="settings-row-help"
|
|
30
|
+
aria-label={`About ${props.label}`}
|
|
31
|
+
class="inline-flex size-4 items-center justify-center rounded-full text-muted-foreground hover:text-foreground focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none"
|
|
32
|
+
>
|
|
33
|
+
<CircleHelp class="size-3.5" aria-hidden="true" />
|
|
34
|
+
</TooltipTrigger>
|
|
35
|
+
<TooltipContent>{props.help}</TooltipContent>
|
|
36
|
+
</Tooltip>
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
|
|
21
40
|
export function SettingsSection(
|
|
22
41
|
props: ComponentProps<'section'> & {
|
|
23
42
|
title: string
|
|
24
43
|
description?: string
|
|
44
|
+
/** A short explanation behind a help icon beside the title; prefer it to a long description. */
|
|
45
|
+
help?: string
|
|
25
46
|
/** A control for the section as a whole, e.g. a master toggle. */
|
|
26
47
|
action?: JSX.Element
|
|
27
48
|
/** Use a spaced content stack for fields, alerts, and cards instead of divided rows. */
|
|
@@ -32,6 +53,7 @@ export function SettingsSection(
|
|
|
32
53
|
'class',
|
|
33
54
|
'title',
|
|
34
55
|
'description',
|
|
56
|
+
'help',
|
|
35
57
|
'action',
|
|
36
58
|
'bodyLayout',
|
|
37
59
|
'children',
|
|
@@ -45,9 +67,12 @@ export function SettingsSection(
|
|
|
45
67
|
>
|
|
46
68
|
<div class="flex items-start justify-between gap-4">
|
|
47
69
|
<div class="flex min-w-0 flex-col gap-0.5">
|
|
48
|
-
<
|
|
49
|
-
|
|
50
|
-
|
|
70
|
+
<div class="flex items-center gap-1.5">
|
|
71
|
+
<Heading as="h2" size="card">
|
|
72
|
+
{local.title}
|
|
73
|
+
</Heading>
|
|
74
|
+
<Show when={local.help}>{(help) => <HelpTip label={local.title} help={help()} />}</Show>
|
|
75
|
+
</div>
|
|
51
76
|
<Show when={local.description}>
|
|
52
77
|
<p class="max-w-prose text-sm text-muted-foreground text-pretty">{local.description}</p>
|
|
53
78
|
</Show>
|
|
@@ -82,6 +107,12 @@ export function SettingsRow(
|
|
|
82
107
|
props: ComponentProps<'div'> & {
|
|
83
108
|
label: string
|
|
84
109
|
description?: string
|
|
110
|
+
/**
|
|
111
|
+
* A short explanation behind a help icon beside the label. Prefer it to a
|
|
112
|
+
* long description: the row stays quiet and the explanation is one hover or
|
|
113
|
+
* focus away.
|
|
114
|
+
*/
|
|
115
|
+
help?: string
|
|
85
116
|
/** Render a vertical stack when the control needs the full width. */
|
|
86
117
|
orientation?: 'horizontal' | 'vertical'
|
|
87
118
|
/** Hide the divider this row would otherwise contribute. */
|
|
@@ -100,6 +131,7 @@ export function SettingsRow(
|
|
|
100
131
|
'class',
|
|
101
132
|
'label',
|
|
102
133
|
'description',
|
|
134
|
+
'help',
|
|
103
135
|
'orientation',
|
|
104
136
|
'bare',
|
|
105
137
|
'flush',
|
|
@@ -132,8 +164,11 @@ export function SettingsRow(
|
|
|
132
164
|
{...rest}
|
|
133
165
|
>
|
|
134
166
|
<div class="flex min-w-0 flex-col gap-0.5">
|
|
135
|
-
<div
|
|
136
|
-
{
|
|
167
|
+
<div class="flex items-center gap-1.5">
|
|
168
|
+
<div id={labelId} data-slot="settings-row-label" class="text-sm font-medium">
|
|
169
|
+
{local.label}
|
|
170
|
+
</div>
|
|
171
|
+
<Show when={local.help}>{(help) => <HelpTip label={local.label} help={help()} />}</Show>
|
|
137
172
|
</div>
|
|
138
173
|
<Show when={local.description}>
|
|
139
174
|
<p id={descriptionId} class="max-w-prose text-sm text-muted-foreground text-pretty">
|
|
@@ -5,7 +5,9 @@ import {
|
|
|
5
5
|
createEffect,
|
|
6
6
|
createMemo,
|
|
7
7
|
createUniqueId,
|
|
8
|
+
For,
|
|
8
9
|
onCleanup,
|
|
10
|
+
Show,
|
|
9
11
|
splitProps,
|
|
10
12
|
useContext,
|
|
11
13
|
} from 'solid-js'
|
|
@@ -29,6 +31,7 @@ type TreeContextValue = {
|
|
|
29
31
|
activate: (id: string, event: Event) => void
|
|
30
32
|
expand: (id: string, expanded: boolean) => void
|
|
31
33
|
disclosure: () => TreeDisclosure
|
|
34
|
+
guides: () => boolean
|
|
32
35
|
onActiveIdChange: (id: string) => void
|
|
33
36
|
}
|
|
34
37
|
|
|
@@ -79,6 +82,12 @@ export type TreeProps = Omit<
|
|
|
79
82
|
* Keyboard behaviour is identical in both modes: Left/Right expand and collapse.
|
|
80
83
|
*/
|
|
81
84
|
disclosure?: TreeDisclosure
|
|
85
|
+
/**
|
|
86
|
+
* Draw a vertical guide line down each ancestor level of a nested row, so a
|
|
87
|
+
* group of children reads as belonging to its parent. Off by default: a flat
|
|
88
|
+
* list or a dense file explorer carries its own rhythm.
|
|
89
|
+
*/
|
|
90
|
+
guides?: boolean
|
|
82
91
|
/** Called for Enter or pointer activation. Domain action stays host-owned. */
|
|
83
92
|
onActivate?: (id: string, event: Event) => void
|
|
84
93
|
/** Called for Space selection. Selection state stays host-owned. */
|
|
@@ -132,6 +141,7 @@ export function Tree(props: TreeProps) {
|
|
|
132
141
|
'onActiveIdChange',
|
|
133
142
|
'onExpand',
|
|
134
143
|
'disclosure',
|
|
144
|
+
'guides',
|
|
135
145
|
'onActivate',
|
|
136
146
|
'onSelectItem',
|
|
137
147
|
'onRequestReveal',
|
|
@@ -296,6 +306,7 @@ export function Tree(props: TreeProps) {
|
|
|
296
306
|
activate: (id, event) => local.onActivate?.(id, event),
|
|
297
307
|
expand: (id, expanded) => local.onExpand?.(id, expanded),
|
|
298
308
|
disclosure: () => local.disclosure ?? 'row',
|
|
309
|
+
guides: () => local.guides ?? false,
|
|
299
310
|
onActiveIdChange: local.onActiveIdChange,
|
|
300
311
|
}
|
|
301
312
|
|
|
@@ -495,6 +506,8 @@ export function TreeRow(props: TreeRowProps) {
|
|
|
495
506
|
'group/tree-row flex min-h-row-sm w-full min-w-0 items-center gap-1.5 rounded-md pe-1.5 text-left text-sm outline-none transition-colors',
|
|
496
507
|
'focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-primary-subtle',
|
|
497
508
|
{
|
|
509
|
+
// Guide lines are positioned against the row; without them it keeps its original classes.
|
|
510
|
+
relative: tree.guides(),
|
|
498
511
|
'bg-primary-subtle text-foreground': local.selected,
|
|
499
512
|
'hover:bg-surface-hover': !local.selected,
|
|
500
513
|
},
|
|
@@ -505,6 +518,18 @@ export function TreeRow(props: TreeRowProps) {
|
|
|
505
518
|
onClick={handleClick}
|
|
506
519
|
onDblClick={handleDoubleClick}
|
|
507
520
|
>
|
|
521
|
+
<Show when={tree.guides() && local.item.level > 1}>
|
|
522
|
+
<For each={Array.from({ length: local.item.level - 1 }, (_, index) => index)}>
|
|
523
|
+
{(index) => (
|
|
524
|
+
<span
|
|
525
|
+
aria-hidden="true"
|
|
526
|
+
data-slot="tree-row-guide"
|
|
527
|
+
class="pointer-events-none absolute inset-y-0 w-px bg-border"
|
|
528
|
+
style={{ 'inset-inline-start': `${6 + index * 12 + 7}px` }}
|
|
529
|
+
/>
|
|
530
|
+
)}
|
|
531
|
+
</For>
|
|
532
|
+
</Show>
|
|
508
533
|
<span
|
|
509
534
|
data-slot={markerTarget() ? 'tree-row-disclosure' : undefined}
|
|
510
535
|
class={cn(
|
|
@@ -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'
|