@adea-ai/ui 0.97.1 → 0.97.3
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/about-dialog/about-dialog.d.ts.map +1 -1
- package/dist/components/composites/about-dialog/about-dialog.js +2 -1
- package/dist/components/composites/about-dialog/about-dialog.js.map +1 -1
- package/dist/components/composites/account-menu/account-menu.d.ts.map +1 -1
- package/dist/components/composites/account-menu/account-menu.js +95 -99
- package/dist/components/composites/account-menu/account-menu.js.map +1 -1
- package/dist/components/composites/appearance-editor/appearance-choices.d.ts.map +1 -1
- package/dist/components/composites/appearance-editor/appearance-choices.js +44 -42
- package/dist/components/composites/appearance-editor/appearance-choices.js.map +1 -1
- package/dist/components/composites/appearance-editor/appearance-editor.d.ts.map +1 -1
- package/dist/components/composites/appearance-editor/appearance-editor.js +23 -1
- package/dist/components/composites/appearance-editor/appearance-editor.js.map +1 -1
- package/dist/components/composites/appearance-editor/appearance-rows.d.ts +22 -5
- package/dist/components/composites/appearance-editor/appearance-rows.d.ts.map +1 -1
- package/dist/components/composites/appearance-editor/appearance-rows.js +249 -152
- package/dist/components/composites/appearance-editor/appearance-rows.js.map +1 -1
- package/dist/components/composites/appearance-editor/appearance-types.d.ts +7 -0
- package/dist/components/composites/appearance-editor/appearance-types.d.ts.map +1 -1
- package/dist/components/composites/appearance-editor/theme-preview.d.ts +10 -0
- package/dist/components/composites/appearance-editor/theme-preview.d.ts.map +1 -1
- package/dist/components/composites/appearance-editor/theme-preview.js +61 -12
- package/dist/components/composites/appearance-editor/theme-preview.js.map +1 -1
- package/dist/r/src/components/composites/about-dialog/about-dialog.tsx +6 -1
- package/dist/r/src/components/composites/account-menu/account-menu.tsx +2 -1
- package/dist/r/src/components/composites/appearance-editor/appearance-choices.tsx +19 -14
- package/dist/r/src/components/composites/appearance-editor/appearance-editor.tsx +13 -1
- package/dist/r/src/components/composites/appearance-editor/appearance-rows.tsx +270 -160
- package/dist/r/src/components/composites/appearance-editor/appearance-types.ts +7 -0
- package/dist/r/src/components/composites/appearance-editor/theme-preview.tsx +58 -0
- package/package.json +1 -1
- package/src/components/composites/about-dialog/about-dialog.tsx +6 -1
- package/src/components/composites/account-menu/account-menu.tsx +2 -1
- package/src/components/composites/appearance-editor/appearance-choices.tsx +19 -14
- package/src/components/composites/appearance-editor/appearance-editor.tsx +13 -1
- package/src/components/composites/appearance-editor/appearance-rows.tsx +270 -160
- package/src/components/composites/appearance-editor/appearance-types.ts +7 -0
- package/src/components/composites/appearance-editor/theme-preview.tsx +58 -0
|
@@ -93,20 +93,31 @@ export function AccentChoices(props: AppearanceEditorProps) {
|
|
|
93
93
|
disabled={props.saving}
|
|
94
94
|
orientation="horizontal"
|
|
95
95
|
aria-label="Accent"
|
|
96
|
-
|
|
96
|
+
// Two chips in a column beside a 3x2 swatch grid: one line of six
|
|
97
|
+
// swatches plus chips overflows the settings sidebar's control column,
|
|
98
|
+
// and the column keeps both rows of the pair balanced (2 x 28px + gap).
|
|
99
|
+
class="flex items-center gap-3"
|
|
97
100
|
onChange={(accent) =>
|
|
98
101
|
props.onChange({
|
|
99
102
|
accent: accent === 'custom' ? (props.customAccentValue ?? '') : accent,
|
|
100
103
|
})
|
|
101
104
|
}
|
|
102
105
|
>
|
|
103
|
-
<
|
|
104
|
-
<Radio.
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
106
|
+
<div class="flex flex-col gap-2">
|
|
107
|
+
<Radio.Item value="theme">
|
|
108
|
+
<Radio.ItemInput />
|
|
109
|
+
<Radio.ItemLabel class="flex h-7 cursor-pointer items-center rounded-md border px-2 text-xs whitespace-nowrap data-[checked]:border-primary data-[checked]:ring-1 data-[checked]:ring-primary">
|
|
110
|
+
Theme default
|
|
111
|
+
</Radio.ItemLabel>
|
|
112
|
+
</Radio.Item>
|
|
113
|
+
<Radio.Item value="custom">
|
|
114
|
+
<Radio.ItemInput />
|
|
115
|
+
<Radio.ItemLabel class="flex h-7 cursor-pointer items-center rounded-md border px-2 text-xs whitespace-nowrap data-[checked]:border-primary data-[checked]:ring-1 data-[checked]:ring-primary">
|
|
116
|
+
Custom
|
|
117
|
+
</Radio.ItemLabel>
|
|
118
|
+
</Radio.Item>
|
|
119
|
+
</div>
|
|
120
|
+
<div class="grid grid-cols-3 gap-2">
|
|
110
121
|
<For each={props.accentOptions}>
|
|
111
122
|
{(option) => (
|
|
112
123
|
<Radio.Item
|
|
@@ -133,12 +144,6 @@ export function AccentChoices(props: AppearanceEditorProps) {
|
|
|
133
144
|
)}
|
|
134
145
|
</For>
|
|
135
146
|
</div>
|
|
136
|
-
<Radio.Item value="custom">
|
|
137
|
-
<Radio.ItemInput />
|
|
138
|
-
<Radio.ItemLabel class="flex h-7 cursor-pointer items-center rounded-md border px-2 text-xs data-[checked]:border-primary data-[checked]:ring-1 data-[checked]:ring-primary">
|
|
139
|
-
Custom
|
|
140
|
-
</Radio.ItemLabel>
|
|
141
|
-
</Radio.Item>
|
|
142
147
|
</Radio>
|
|
143
148
|
)
|
|
144
149
|
}
|
|
@@ -47,7 +47,7 @@ import {
|
|
|
47
47
|
} from '../../ui/popover/popover'
|
|
48
48
|
import type { AppearanceEditorProps, AppearancePopoverProps } from './appearance-types'
|
|
49
49
|
import { ModeChoices, AccentChoices, GlassChoices } from './appearance-choices'
|
|
50
|
-
import { SettingsRow, ThemeRow } from './appearance-rows'
|
|
50
|
+
import { SettingsRow, TerminalRow, ThemeRow } from './appearance-rows'
|
|
51
51
|
|
|
52
52
|
/**
|
|
53
53
|
* A live editor whose persistence and preview authority belong to its host.
|
|
@@ -99,6 +99,18 @@ export function AppearanceEditor(props: AppearanceEditorProps) {
|
|
|
99
99
|
disabled={props.saving}
|
|
100
100
|
onSelect={(darkThemeId) => props.onChange({ darkThemeId })}
|
|
101
101
|
/>
|
|
102
|
+
{/* The row exists only when the host carries a terminal preference:
|
|
103
|
+
the draft field is the show/hide switch, so the editor can never
|
|
104
|
+
render a terminal row that writes nothing. */}
|
|
105
|
+
<Show when={props.draft.terminalThemeId !== undefined}>
|
|
106
|
+
<TerminalRow
|
|
107
|
+
selectedId={props.draft.terminalThemeId ?? 'theme'}
|
|
108
|
+
preview={props.resolvedAppearance === 'dark' ? props.darkTheme : props.lightTheme}
|
|
109
|
+
themes={props.themes}
|
|
110
|
+
disabled={props.saving}
|
|
111
|
+
onSelect={(terminalThemeId) => props.onChange({ terminalThemeId })}
|
|
112
|
+
/>
|
|
113
|
+
</Show>
|
|
102
114
|
<SettingsRow title="Accent" icon={<SlidersHorizontal />} description={accentDescription()}>
|
|
103
115
|
<AccentChoices {...props} />
|
|
104
116
|
</SettingsRow>
|
|
@@ -23,9 +23,12 @@
|
|
|
23
23
|
*/
|
|
24
24
|
// Retains the compact divided row and palette-preview selector from the pinned
|
|
25
25
|
// Zeron/Adea composition; Kobalte owns option navigation and nested dismissal.
|
|
26
|
+
// One shared menu serves the light, dark, and terminal rows: each option is a
|
|
27
|
+
// compact theme preview with its name and one-line description beside it, and
|
|
28
|
+
// the menu sizes to that content instead of clipping it at the trigger width.
|
|
26
29
|
import type { AdeaTheme, AdeaThemeRecord } from '@adea-ai/themes'
|
|
27
30
|
import { For, Show, createMemo, createSignal, onCleanup, type JSX } from 'solid-js'
|
|
28
|
-
import { ChevronDown, Palette } from 'lucide-solid'
|
|
31
|
+
import { ChevronDown, Palette, SquareTerminal } from 'lucide-solid'
|
|
29
32
|
import { cn } from '#lib/utils'
|
|
30
33
|
import { Button } from '../../ui/button/button'
|
|
31
34
|
import {
|
|
@@ -35,7 +38,7 @@ import {
|
|
|
35
38
|
DropdownMenuRadioItem,
|
|
36
39
|
DropdownMenuTrigger,
|
|
37
40
|
} from '../../ui/dropdown-menu/dropdown-menu'
|
|
38
|
-
import { PalettePreview } from './theme-preview'
|
|
41
|
+
import { PalettePreview, ThemeMenuPreview } from './theme-preview'
|
|
39
42
|
|
|
40
43
|
const POINTER_FOCUS_TARGET_SELECTOR =
|
|
41
44
|
'a[href], area[href], button, input, select, textarea, iframe, [tabindex], [contenteditable], details > summary:first-of-type'
|
|
@@ -87,18 +90,30 @@ export function SettingsRow(props: {
|
|
|
87
90
|
)
|
|
88
91
|
}
|
|
89
92
|
|
|
93
|
+
/** One selectable palette in a theme menu. */
|
|
94
|
+
export type ThemeOption = {
|
|
95
|
+
id: string
|
|
96
|
+
name: string
|
|
97
|
+
description?: string
|
|
98
|
+
preview: AdeaTheme
|
|
99
|
+
}
|
|
100
|
+
|
|
90
101
|
/**
|
|
91
|
-
*
|
|
92
|
-
*
|
|
102
|
+
* The shared theme picker. Kobalte handles menu navigation and dismissal;
|
|
103
|
+
* this component moves Tab to the adjacent browser-reported stop because
|
|
93
104
|
* menus consume Tab by default. Enumerating elements and checking their native
|
|
94
105
|
* tabIndex keeps details summaries in the order while preserving explicit
|
|
95
|
-
* tabindex overrides.
|
|
106
|
+
* tabindex overrides. The menu width follows its content (bounded by the
|
|
107
|
+
* popper's available width) so a long theme name and its description render
|
|
108
|
+
* fully instead of truncating at the trigger's width.
|
|
96
109
|
*/
|
|
97
|
-
|
|
98
|
-
|
|
110
|
+
function ThemeSelectMenu(props: {
|
|
111
|
+
/** Row title; also the trigger's accessible name and the group's label. */
|
|
112
|
+
label: string
|
|
99
113
|
selectedId: string
|
|
100
|
-
preview
|
|
101
|
-
|
|
114
|
+
/** Palette shown on the trigger; hosts pass the accent-applied preview. */
|
|
115
|
+
triggerPreview: AdeaTheme
|
|
116
|
+
options: readonly ThemeOption[]
|
|
102
117
|
disabled?: boolean
|
|
103
118
|
onSelect: (id: string) => void
|
|
104
119
|
}) {
|
|
@@ -112,15 +127,11 @@ export function ThemeRow(props: {
|
|
|
112
127
|
onCleanup(() => {
|
|
113
128
|
focusLifecycleVersion += 1
|
|
114
129
|
})
|
|
115
|
-
const options = createMemo(() =>
|
|
116
|
-
props.themes.filter((theme) => theme.appearance === props.appearance)
|
|
117
|
-
)
|
|
118
130
|
const selected = createMemo(
|
|
119
131
|
() =>
|
|
120
|
-
options
|
|
121
|
-
options
|
|
132
|
+
props.options.find((option) => option.id === props.selectedId) ??
|
|
133
|
+
props.options.find((option) => option.id === props.triggerPreview.id)
|
|
122
134
|
)
|
|
123
|
-
const label = () => (props.appearance === 'light' ? 'Light theme' : 'Dark theme')
|
|
124
135
|
const closeMenu = () => {
|
|
125
136
|
if (menuOpen()) focusLifecycleVersion += 1
|
|
126
137
|
setMenuOpen(false)
|
|
@@ -166,160 +177,259 @@ export function ThemeRow(props: {
|
|
|
166
177
|
}
|
|
167
178
|
}
|
|
168
179
|
return (
|
|
169
|
-
<
|
|
170
|
-
<
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
180
|
+
<div ref={setPortalMount}>
|
|
181
|
+
<DropdownMenu
|
|
182
|
+
modal={false}
|
|
183
|
+
open={menuOpen()}
|
|
184
|
+
onOpenChange={(open) => {
|
|
185
|
+
if (open !== menuOpen()) focusLifecycleVersion += 1
|
|
186
|
+
setMenuOpen(open)
|
|
187
|
+
if (open) {
|
|
188
|
+
focusTargetAfterTab = undefined
|
|
189
|
+
setFocusOutsideTarget(undefined)
|
|
190
|
+
setRestoreTriggerFocus(false)
|
|
191
|
+
}
|
|
192
|
+
}}
|
|
193
|
+
>
|
|
194
|
+
<DropdownMenuTrigger
|
|
195
|
+
ref={setTriggerElement}
|
|
196
|
+
as={Button}
|
|
197
|
+
variant="outline"
|
|
198
|
+
size="sm"
|
|
199
|
+
aria-label={props.label}
|
|
200
|
+
disabled={props.disabled || props.options.length === 0}
|
|
201
|
+
class="w-full justify-between sm:w-52"
|
|
183
202
|
>
|
|
184
|
-
<
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
size="sm"
|
|
189
|
-
aria-label={label()}
|
|
190
|
-
disabled={props.disabled || options().length === 0}
|
|
191
|
-
class="w-full justify-between sm:w-52"
|
|
192
|
-
>
|
|
193
|
-
<span class="flex min-w-0 flex-1 items-center gap-2">
|
|
194
|
-
<PalettePreview theme={props.preview} />
|
|
195
|
-
<span class="min-w-0 flex-1 truncate">{selected()?.name ?? props.preview.name}</span>
|
|
203
|
+
<span class="flex min-w-0 flex-1 items-center gap-2">
|
|
204
|
+
<PalettePreview theme={props.triggerPreview} />
|
|
205
|
+
<span class="min-w-0 flex-1 truncate">
|
|
206
|
+
{selected()?.name ?? props.triggerPreview.name}
|
|
196
207
|
</span>
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
setFocusOutsideTarget(focusTarget)
|
|
213
|
-
} else {
|
|
214
|
-
setRestoreTriggerFocus(true)
|
|
215
|
-
}
|
|
216
|
-
}}
|
|
217
|
-
onFocusOutside={(event) => {
|
|
218
|
-
// The containing dialog or document body can briefly reclaim
|
|
219
|
-
// focus when the menu opens. Keep that handoff from dismissing
|
|
220
|
-
// it; focus moving to another descendant still closes the menu.
|
|
221
|
-
const parentDialog = portalMount()?.closest<HTMLElement>('[role="dialog"]')
|
|
222
|
-
const focusScope = parentDialog ?? portalMount()?.ownerDocument.body
|
|
223
|
-
const target = event.detail.originalEvent.target
|
|
224
|
-
if (target === focusScope) {
|
|
225
|
-
event.preventDefault()
|
|
226
|
-
} else if (target instanceof HTMLElement && focusScope?.contains(target)) {
|
|
227
|
-
setRestoreTriggerFocus(false)
|
|
228
|
-
setFocusOutsideTarget(target)
|
|
229
|
-
}
|
|
230
|
-
}}
|
|
231
|
-
onEscapeKeyDown={() => setRestoreTriggerFocus(true)}
|
|
232
|
-
onCloseAutoFocus={(event) => {
|
|
233
|
-
// A prior menu instance can finish its focus cleanup after this
|
|
234
|
-
// row has already reopened. Do not let that cleanup steal focus.
|
|
235
|
-
if (menuOpen()) {
|
|
236
|
-
event.preventDefault()
|
|
237
|
-
return
|
|
238
|
-
}
|
|
208
|
+
</span>
|
|
209
|
+
<ChevronDown aria-hidden="true" class="shrink-0 text-muted-foreground" />
|
|
210
|
+
</DropdownMenuTrigger>
|
|
211
|
+
<DropdownMenuContent
|
|
212
|
+
portalMount={portalMount()}
|
|
213
|
+
// Sized to content (at least the trigger width, never wider than the
|
|
214
|
+
// popper allows) so long theme names render in full with padding for
|
|
215
|
+
// the selection indicator instead of truncating at the anchor width.
|
|
216
|
+
class="max-h-(--kb-popper-content-available-height) w-max min-w-(--kb-popper-anchor-width) max-w-(--kb-popper-content-available-width) overflow-x-hidden overflow-y-auto"
|
|
217
|
+
onInteractOutside={(event) => {
|
|
218
|
+
if (event.detail.originalEvent.type !== 'pointerdown') return
|
|
219
|
+
const parentDialog = portalMount()?.closest<HTMLElement>('[role="dialog"]')
|
|
220
|
+
const focusScope = parentDialog ?? portalMount()?.ownerDocument.body
|
|
221
|
+
const target = event.detail.originalEvent.target
|
|
222
|
+
if (!(target instanceof Element) || !focusScope?.contains(target)) return
|
|
239
223
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
224
|
+
const focusTarget = target.closest<HTMLElement>(POINTER_FOCUS_TARGET_SELECTOR)
|
|
225
|
+
if (focusTarget && isTabStop(focusTarget)) {
|
|
226
|
+
setRestoreTriggerFocus(false)
|
|
227
|
+
setFocusOutsideTarget(focusTarget)
|
|
228
|
+
} else {
|
|
229
|
+
setRestoreTriggerFocus(true)
|
|
230
|
+
}
|
|
231
|
+
}}
|
|
232
|
+
onFocusOutside={(event) => {
|
|
233
|
+
// The containing dialog or document body can briefly reclaim
|
|
234
|
+
// focus when the menu opens. Keep that handoff from dismissing
|
|
235
|
+
// it; focus moving to another descendant still closes the menu.
|
|
236
|
+
const parentDialog = portalMount()?.closest<HTMLElement>('[role="dialog"]')
|
|
237
|
+
const focusScope = parentDialog ?? portalMount()?.ownerDocument.body
|
|
238
|
+
const target = event.detail.originalEvent.target
|
|
239
|
+
if (target === focusScope) {
|
|
240
|
+
event.preventDefault()
|
|
241
|
+
} else if (target instanceof HTMLElement && focusScope?.contains(target)) {
|
|
245
242
|
setRestoreTriggerFocus(false)
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
243
|
+
setFocusOutsideTarget(target)
|
|
244
|
+
}
|
|
245
|
+
}}
|
|
246
|
+
onEscapeKeyDown={() => setRestoreTriggerFocus(true)}
|
|
247
|
+
onCloseAutoFocus={(event) => {
|
|
248
|
+
// A prior menu instance can finish its focus cleanup after this
|
|
249
|
+
// row has already reopened. Do not let that cleanup steal focus.
|
|
250
|
+
if (menuOpen()) {
|
|
251
|
+
event.preventDefault()
|
|
252
|
+
return
|
|
253
|
+
}
|
|
253
254
|
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
255
|
+
const lifecycleVersion = focusLifecycleVersion
|
|
256
|
+
const shouldRestore = restoreTriggerFocus()
|
|
257
|
+
const focusTarget = focusTargetAfterTab ?? focusOutsideTarget()
|
|
258
|
+
focusTargetAfterTab = undefined
|
|
259
|
+
setFocusOutsideTarget(undefined)
|
|
260
|
+
setRestoreTriggerFocus(false)
|
|
261
|
+
if (shouldRestore) {
|
|
262
|
+
// The saved focus may be the containing dialog rather than
|
|
263
|
+
// the trigger when WebKit opens this menu from a pointer click.
|
|
264
|
+
event.preventDefault()
|
|
265
|
+
triggerElement()?.focus({ preventScroll: true })
|
|
266
|
+
return
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const parentDialog = portalMount()?.closest<HTMLElement>('[role="dialog"]')
|
|
270
|
+
const focusScope = parentDialog ?? portalMount()?.ownerDocument.body
|
|
271
|
+
if (focusTarget?.isConnected && focusScope?.contains(focusTarget)) {
|
|
272
|
+
// Preserve the intended in-scope destination across
|
|
273
|
+
// Kobalte's deferred stale-focus restore.
|
|
274
|
+
event.preventDefault()
|
|
275
|
+
queueMicrotask(() => {
|
|
276
|
+
if (lifecycleVersion !== focusLifecycleVersion || menuOpen()) return
|
|
262
277
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
278
|
+
const activeElement = document.activeElement
|
|
279
|
+
const isMenuFocus =
|
|
280
|
+
activeElement instanceof HTMLElement &&
|
|
281
|
+
activeElement.closest('[role="menu"]') !== null
|
|
282
|
+
const isTriggerFocus = activeElement === triggerElement()
|
|
283
|
+
if (
|
|
284
|
+
activeElement !== focusTarget &&
|
|
285
|
+
(activeElement === document.body ||
|
|
286
|
+
activeElement === focusScope ||
|
|
287
|
+
isMenuFocus ||
|
|
288
|
+
isTriggerFocus)
|
|
289
|
+
) {
|
|
290
|
+
focusTarget.focus({ preventScroll: true })
|
|
291
|
+
}
|
|
292
|
+
})
|
|
293
|
+
return
|
|
294
|
+
}
|
|
280
295
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
296
|
+
const activeElement = document.activeElement
|
|
297
|
+
if (
|
|
298
|
+
focusScope &&
|
|
299
|
+
activeElement instanceof HTMLElement &&
|
|
300
|
+
activeElement !== focusScope &&
|
|
301
|
+
focusScope.contains(activeElement) &&
|
|
302
|
+
activeElement.closest('[role="menu"]') === null
|
|
303
|
+
) {
|
|
304
|
+
// Preserve a real focus target in this scope instead of
|
|
305
|
+
// restoring the stale element captured when the menu mounted. A
|
|
306
|
+
// focused menu item is about to unmount, so Kobalte must restore
|
|
307
|
+
// focus to the trigger instead.
|
|
308
|
+
event.preventDefault()
|
|
309
|
+
}
|
|
310
|
+
}}
|
|
311
|
+
>
|
|
312
|
+
<DropdownMenuRadioGroup
|
|
313
|
+
value={selected()?.id ?? ''}
|
|
314
|
+
aria-label={`${props.label} options`}
|
|
315
|
+
on:keydown={moveFocusOnTab}
|
|
316
|
+
onChange={(id) => {
|
|
317
|
+
if (!props.disabled && typeof id === 'string') props.onSelect(id)
|
|
295
318
|
}}
|
|
296
319
|
>
|
|
297
|
-
<
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
<
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
320
|
+
<For each={props.options}>
|
|
321
|
+
{(option) => (
|
|
322
|
+
<DropdownMenuRadioItem
|
|
323
|
+
value={option.id}
|
|
324
|
+
textValue={option.name}
|
|
325
|
+
closeOnSelect={true}
|
|
326
|
+
disabled={props.disabled}
|
|
327
|
+
onSelect={() => setRestoreTriggerFocus(true)}
|
|
328
|
+
>
|
|
329
|
+
<ThemeMenuPreview theme={option.preview} />
|
|
330
|
+
<span class="flex min-w-0 flex-1 flex-col gap-0.5">
|
|
331
|
+
<span class="truncate">{option.name}</span>
|
|
332
|
+
{/* Visual affordance only: the accessible name stays the
|
|
333
|
+
theme name, which is what keyboard and test callers
|
|
334
|
+
match on. */}
|
|
335
|
+
<Show when={option.description}>
|
|
336
|
+
<span
|
|
337
|
+
aria-hidden="true"
|
|
338
|
+
class="line-clamp-2 max-w-44 text-2xs text-muted-foreground"
|
|
339
|
+
>
|
|
340
|
+
{option.description}
|
|
341
|
+
</span>
|
|
342
|
+
</Show>
|
|
343
|
+
</span>
|
|
344
|
+
</DropdownMenuRadioItem>
|
|
345
|
+
)}
|
|
346
|
+
</For>
|
|
347
|
+
</DropdownMenuRadioGroup>
|
|
348
|
+
</DropdownMenuContent>
|
|
349
|
+
</DropdownMenu>
|
|
350
|
+
</div>
|
|
351
|
+
)
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* A compact persistent theme choice for one appearance.
|
|
356
|
+
*/
|
|
357
|
+
export function ThemeRow(props: {
|
|
358
|
+
appearance: 'light' | 'dark'
|
|
359
|
+
selectedId: string
|
|
360
|
+
preview: AdeaTheme
|
|
361
|
+
themes: readonly AdeaThemeRecord[]
|
|
362
|
+
disabled?: boolean
|
|
363
|
+
onSelect: (id: string) => void
|
|
364
|
+
}) {
|
|
365
|
+
const label = () => (props.appearance === 'light' ? 'Light theme' : 'Dark theme')
|
|
366
|
+
const options = createMemo<ThemeOption[]>(() =>
|
|
367
|
+
props.themes
|
|
368
|
+
.filter((theme) => theme.appearance === props.appearance)
|
|
369
|
+
.map((theme) => ({
|
|
370
|
+
id: theme.id,
|
|
371
|
+
name: theme.name,
|
|
372
|
+
description: theme.description,
|
|
373
|
+
preview: theme,
|
|
374
|
+
}))
|
|
375
|
+
)
|
|
376
|
+
return (
|
|
377
|
+
<SettingsRow title={label()} icon={<Palette />}>
|
|
378
|
+
<ThemeSelectMenu
|
|
379
|
+
label={label()}
|
|
380
|
+
selectedId={props.selectedId}
|
|
381
|
+
triggerPreview={props.preview}
|
|
382
|
+
options={options()}
|
|
383
|
+
disabled={props.disabled}
|
|
384
|
+
onSelect={props.onSelect}
|
|
385
|
+
/>
|
|
386
|
+
</SettingsRow>
|
|
387
|
+
)
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* A terminal palette choice independent of the light/dark rows. The first
|
|
392
|
+
* option (`'theme'`) follows the interface theme, painted with the resolved
|
|
393
|
+
* preview; every catalogue record follows, both appearances, because the
|
|
394
|
+
* terminal paints one fixed ANSI palette rather than an appearance axis.
|
|
395
|
+
*/
|
|
396
|
+
export function TerminalRow(props: {
|
|
397
|
+
selectedId: string
|
|
398
|
+
/** Resolved interface theme: the `theme` option's palette and the fallback. */
|
|
399
|
+
preview: AdeaTheme
|
|
400
|
+
themes: readonly AdeaThemeRecord[]
|
|
401
|
+
disabled?: boolean
|
|
402
|
+
onSelect: (id: string) => void
|
|
403
|
+
}) {
|
|
404
|
+
const options = createMemo<ThemeOption[]>(() => [
|
|
405
|
+
{
|
|
406
|
+
id: 'theme',
|
|
407
|
+
name: 'UI theme',
|
|
408
|
+
description: 'Uses the interface theme.',
|
|
409
|
+
preview: props.preview,
|
|
410
|
+
},
|
|
411
|
+
...props.themes.map((theme) => ({
|
|
412
|
+
id: theme.id,
|
|
413
|
+
name: theme.name,
|
|
414
|
+
description: theme.description,
|
|
415
|
+
preview: theme,
|
|
416
|
+
})),
|
|
417
|
+
])
|
|
418
|
+
const selectedOption = () => options().find((option) => option.id === props.selectedId)
|
|
419
|
+
const description = () =>
|
|
420
|
+
selectedOption() && selectedOption()?.id !== 'theme'
|
|
421
|
+
? `The terminal follows ${selectedOption()?.name}.`
|
|
422
|
+
: 'The terminal follows the interface theme.'
|
|
423
|
+
return (
|
|
424
|
+
<SettingsRow title="Terminal" icon={<SquareTerminal />} description={description()}>
|
|
425
|
+
<ThemeSelectMenu
|
|
426
|
+
label="Terminal"
|
|
427
|
+
selectedId={props.selectedId}
|
|
428
|
+
triggerPreview={selectedOption()?.preview ?? props.preview}
|
|
429
|
+
options={options()}
|
|
430
|
+
disabled={props.disabled}
|
|
431
|
+
onSelect={props.onSelect}
|
|
432
|
+
/>
|
|
323
433
|
</SettingsRow>
|
|
324
434
|
)
|
|
325
435
|
}
|
|
@@ -4,6 +4,13 @@ export type AppearanceDraft = Readonly<{
|
|
|
4
4
|
mode: 'system' | 'light' | 'dark'
|
|
5
5
|
lightThemeId: string
|
|
6
6
|
darkThemeId: string
|
|
7
|
+
/**
|
|
8
|
+
* The terminal palette: `'theme'` follows the interface theme, a theme id
|
|
9
|
+
* pins that theme's terminal colours. Optional so a host without a terminal
|
|
10
|
+
* preference renders no terminal row — the row's presence is this field's
|
|
11
|
+
* presence, not a second prop that could disagree with the draft.
|
|
12
|
+
*/
|
|
13
|
+
terminalThemeId?: string
|
|
7
14
|
/** Theme default, a canonical preset id, or the host's custom accent draft. */
|
|
8
15
|
accent: string
|
|
9
16
|
surface: 'theme' | 'frosted' | 'opaque'
|
|
@@ -105,6 +105,64 @@ export function ThemeMiniatureSplit(props: { light: AdeaTheme; dark: AdeaTheme;
|
|
|
105
105
|
)
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
+
/**
|
|
109
|
+
* The dropdown-item preview: the ThemePreview composition (rail + panel +
|
|
110
|
+
* text bars + action row) at menu scale, so a menu item carries the theme's
|
|
111
|
+
* actual chrome rather than the three-band trigger swatch. `aria-hidden` —
|
|
112
|
+
* the item's accessible name stays the theme name.
|
|
113
|
+
*/
|
|
114
|
+
export function ThemeMenuPreview(props: { theme: AdeaTheme; class?: string }) {
|
|
115
|
+
const colors = () => props.theme.colors
|
|
116
|
+
return (
|
|
117
|
+
<span
|
|
118
|
+
data-theme-menu-preview
|
|
119
|
+
aria-hidden="true"
|
|
120
|
+
class={cn(
|
|
121
|
+
'flex h-16 w-28 shrink-0 overflow-hidden rounded-md border border-(--appearance-preview-border)',
|
|
122
|
+
props.class
|
|
123
|
+
)}
|
|
124
|
+
style={{
|
|
125
|
+
'background-color': colors().surface,
|
|
126
|
+
'--appearance-preview-border': colors().border,
|
|
127
|
+
}}
|
|
128
|
+
>
|
|
129
|
+
<span
|
|
130
|
+
class="flex w-5 shrink-0 flex-col items-center gap-1.5 border-e border-(--appearance-preview-border) pt-2"
|
|
131
|
+
style={{ 'background-color': colors().surfaceElevated }}
|
|
132
|
+
>
|
|
133
|
+
<span class="size-1.5 rounded-[3px]" style={{ 'background-color': colors().accent }} />
|
|
134
|
+
<span
|
|
135
|
+
class="size-1.5 rounded-[3px]"
|
|
136
|
+
style={{ 'background-color': colors().surfaceActive }}
|
|
137
|
+
/>
|
|
138
|
+
<span
|
|
139
|
+
class="size-1.5 rounded-[3px]"
|
|
140
|
+
style={{ 'background-color': colors().surfaceActive }}
|
|
141
|
+
/>
|
|
142
|
+
</span>
|
|
143
|
+
<span
|
|
144
|
+
class="m-1 flex min-w-0 flex-1 flex-col gap-1 rounded-md border border-(--appearance-preview-border) p-1.5"
|
|
145
|
+
style={{
|
|
146
|
+
'background-color': colors().background,
|
|
147
|
+
'--appearance-preview-border': colors().border,
|
|
148
|
+
}}
|
|
149
|
+
>
|
|
150
|
+
<span class="h-1.5 w-8 rounded-full" style={{ 'background-color': colors().text }} />
|
|
151
|
+
<span class="h-1 w-full rounded-full" style={{ 'background-color': colors().textMuted }} />
|
|
152
|
+
<span class="h-1 w-3/4 rounded-full" style={{ 'background-color': colors().textMuted }} />
|
|
153
|
+
<span class="mt-auto flex items-center gap-1">
|
|
154
|
+
<span class="h-2.5 w-7 rounded-[3px]" style={{ 'background-color': colors().accent }} />
|
|
155
|
+
<span class="h-2.5 w-4 rounded-[3px]" style={{ 'background-color': colors().error }} />
|
|
156
|
+
<span
|
|
157
|
+
class="h-2.5 w-4 rounded-[3px]"
|
|
158
|
+
style={{ 'background-color': colors().surfaceActive }}
|
|
159
|
+
/>
|
|
160
|
+
</span>
|
|
161
|
+
</span>
|
|
162
|
+
</span>
|
|
163
|
+
)
|
|
164
|
+
}
|
|
165
|
+
|
|
108
166
|
export function PalettePreview(props: { theme: AdeaTheme; class?: string }) {
|
|
109
167
|
const bands = () => [
|
|
110
168
|
props.theme.colors.surface,
|