Franknoirot/settings search (#2270)

* Search and highlight, no scroll yet

* Tweak toggle look

* Style search, fix state changes

* Separate out settings components

* Include description in results, minor style tweaks

* Fix tsc import

* Remove unused imports in Settings

* fmt
This commit is contained in:
Frank Noirot
2024-04-30 14:37:32 -04:00
committed by GitHub
parent 834967df6a
commit 23181d8144
10 changed files with 495 additions and 304 deletions

View File

@ -0,0 +1,152 @@
import { Toggle } from 'components/Toggle/Toggle'
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
import { Setting } from 'lib/settings/initialSettings'
import {
SetEventTypes,
SettingsLevel,
WildcardSetEvent,
} from 'lib/settings/settingsTypes'
import { getSettingInputType } from 'lib/settings/settingsUtils'
import { useMemo } from 'react'
import { Event } from 'xstate'
interface SettingsFieldInputProps {
// We don't need the fancy types here,
// it doesn't help us with autocomplete or anything
category: string
settingName: string
settingsLevel: SettingsLevel
setting: Setting<unknown>
}
export function SettingsFieldInput({
category,
settingName,
settingsLevel,
setting,
}: SettingsFieldInputProps) {
const {
settings: { context, send },
} = useSettingsAuthContext()
const options = useMemo(() => {
return setting.commandConfig &&
'options' in setting.commandConfig &&
setting.commandConfig.options
? setting.commandConfig.options instanceof Array
? setting.commandConfig.options
: setting.commandConfig.options(
{
argumentsToSubmit: {
level: settingsLevel,
},
},
context
)
: []
}, [setting, settingsLevel, context])
const inputType = getSettingInputType(setting)
switch (inputType) {
case 'component':
return (
setting.Component && (
<setting.Component
value={setting[settingsLevel] || setting.getFallback(settingsLevel)}
updateValue={(newValue) => {
send({
type: `set.${category}.${settingName}`,
data: {
level: settingsLevel,
value: newValue,
},
} as unknown as Event<WildcardSetEvent>)
}}
/>
)
)
case 'boolean':
return (
<Toggle
offLabel="Off"
onLabel="On"
onChange={(e) =>
send({
type: `set.${category}.${settingName}`,
data: {
level: settingsLevel,
value: Boolean(e.target.checked),
},
} as SetEventTypes)
}
checked={Boolean(
setting[settingsLevel] !== undefined
? setting[settingsLevel]
: setting.getFallback(settingsLevel)
)}
name={`${category}-${settingName}`}
data-testid={`${category}-${settingName}`}
/>
)
case 'options':
return (
<select
name={`${category}-${settingName}`}
data-testid={`${category}-${settingName}`}
className="p-1 bg-transparent border rounded-sm border-chalkboard-30 w-full"
value={String(
setting[settingsLevel] || setting.getFallback(settingsLevel)
)}
onChange={(e) =>
send({
type: `set.${category}.${settingName}`,
data: {
level: settingsLevel,
value: e.target.value,
},
} as unknown as Event<WildcardSetEvent>)
}
>
{options &&
options.length > 0 &&
options.map((option) => (
<option key={option.name} value={String(option.value)}>
{option.name}
</option>
))}
</select>
)
case 'string':
return (
<input
name={`${category}-${settingName}`}
data-testid={`${category}-${settingName}`}
type="text"
className="p-1 bg-transparent border rounded-sm border-chalkboard-30 w-full"
defaultValue={String(
setting[settingsLevel] || setting.getFallback(settingsLevel)
)}
onBlur={(e) => {
if (
setting[settingsLevel] === undefined
? setting.getFallback(settingsLevel) !== e.target.value
: setting[settingsLevel] !== e.target.value
) {
send({
type: `set.${category}.${settingName}`,
data: {
level: settingsLevel,
value: e.target.value,
},
} as unknown as Event<WildcardSetEvent>)
}
}}
/>
)
}
return (
<p className="text-destroy-70 dark:text-destroy-20">
No component or input type found for setting {settingName} in category{' '}
{category}
</p>
)
}

View File

@ -0,0 +1,110 @@
import { Combobox } from '@headlessui/react'
import { CustomIcon } from 'components/CustomIcon'
import decamelize from 'decamelize'
import Fuse from 'fuse.js'
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
import { Setting } from 'lib/settings/initialSettings'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useHotkeys } from 'react-hotkeys-hook'
import { useNavigate } from 'react-router-dom'
export function SettingsSearchBar() {
const inputRef = useRef<HTMLInputElement>(null)
useHotkeys(
'Ctrl+.',
(e) => {
e.preventDefault()
inputRef.current?.focus()
},
{ enableOnFormTags: true }
)
const navigate = useNavigate()
const [query, setQuery] = useState('')
const { settings } = useSettingsAuthContext()
const settingsAsSearchable = useMemo(
() =>
Object.entries(settings.state.context).flatMap(
([category, categorySettings]) =>
Object.entries(categorySettings).flatMap(([settingName, setting]) => {
const s = setting as Setting
return ['project', 'user']
.filter((l) => s.hideOnLevel !== l)
.map((l) => ({
category: decamelize(category, { separator: ' ' }),
settingName: settingName,
settingNameDisplay: decamelize(settingName, { separator: ' ' }),
setting: s,
level: l,
}))
})
),
[settings.state.context]
)
const [searchResults, setSearchResults] = useState(settingsAsSearchable)
const fuse = new Fuse(settingsAsSearchable, {
keys: ['category', 'settingNameDisplay', 'setting.description'],
includeScore: true,
})
useEffect(() => {
const results = fuse.search(query).map((result) => result.item)
setSearchResults(query.length > 0 ? results : settingsAsSearchable)
}, [query])
function handleSelection({
level,
settingName,
}: {
category: string
settingName: string
setting: Setting<unknown>
level: string
}) {
navigate(`?tab=${level}#${settingName}`)
}
return (
<Combobox onChange={handleSelection}>
<div className="relative group">
<div className="flex items-center gap-2 py-0.5 pr-1 pl-2 rounded border-solid border border-primary/10 dark:border-chalkboard-80 focus-within:border-primary dark:focus-within:border-chalkboard-30">
<Combobox.Input
ref={inputRef}
onChange={(event) => setQuery(event.target.value)}
className="w-full bg-transparent focus:outline-none selection:bg-primary/20 dark:selection:bg-primary/40 dark:focus:outline-none"
placeholder="Search settings (^.)"
autoCapitalize="off"
autoComplete="off"
autoCorrect="off"
spellCheck="false"
autoFocus
/>
<CustomIcon
name="search"
className="w-5 h-5 rounded-sm bg-primary/10 text-primary group-focus-within:bg-primary group-focus-within:text-chalkboard-10"
/>
</div>
<Combobox.Options className="absolute top-full mt-2 right-0 w-80 overflow-y-auto z-50 max-h-96 cursor-pointer bg-chalkboard-10 dark:bg-chalkboard-100 border border-solid border-primary dark:border-chalkboard-30 rounded">
{searchResults?.map((option) => (
<Combobox.Option
key={`${option.category}-${option.settingName}-${option.level}`}
value={option}
className="flex flex-col items-start gap-2 px-4 py-2 ui-active:bg-primary/10 dark:ui-active:bg-chalkboard-90"
>
<p className="flex-grow text-base capitalize m-0 leading-none">
{option.level} ·{' '}
{decamelize(option.category, { separator: ' ' })} ·{' '}
{option.settingNameDisplay}
</p>
{option.setting.description && (
<p className="text-xs leading-tight text-chalkboard-70 dark:text-chalkboard-50">
{option.setting.description}
</p>
)}
</Combobox.Option>
))}
</Combobox.Options>
</div>
</Combobox>
)
}

View File

@ -0,0 +1,60 @@
import { CustomIcon } from 'components/CustomIcon'
import Tooltip from 'components/Tooltip'
import { SettingsLevel } from 'lib/settings/settingsTypes'
interface SettingsSectionProps extends React.HTMLProps<HTMLDivElement> {
title: string
description?: string
className?: string
parentLevel?: SettingsLevel | 'default'
onFallback?: () => void
settingHasChanged?: boolean
headingClassName?: string
}
export function SettingsSection({
title,
id,
description,
className,
children,
parentLevel,
settingHasChanged,
onFallback,
headingClassName = 'text-lg font-normal capitalize tracking-wide',
}: SettingsSectionProps) {
return (
<section
id={id}
className={
'group p-2 pl-0 grid grid-cols-2 gap-6 items-start ' +
className +
(settingHasChanged ? ' border-0 border-l-2 -ml-0.5 border-primary' : '')
}
>
<div className="ml-2">
<div className="flex items-center gap-2">
<h2 className={headingClassName}>{title}</h2>
{onFallback && parentLevel && settingHasChanged && (
<button
onClick={onFallback}
className="hidden group-hover:block group-focus-within:block border-none p-0 hover:bg-warn-10 dark:hover:bg-warn-80 focus:bg-warn-10 dark:focus:bg-warn-80 focus:outline-none"
>
<CustomIcon name="refresh" className="w-4 h-4" />
<span className="sr-only">Roll back {title}</span>
<Tooltip position="right">
Roll back to match {parentLevel}
</Tooltip>
</button>
)}
</div>
{description && (
<p className="mt-2 text-xs text-chalkboard-80 dark:text-chalkboard-30">
{description}
</p>
)}
</div>
<div>{children}</div>
</section>
)
}

View File

@ -0,0 +1,28 @@
import { CustomIcon, CustomIconName } from 'components/CustomIcon'
interface SettingsTabButtonProps {
checked: boolean
icon: CustomIconName
text: string
}
export function SettingsTabButton(props: SettingsTabButtonProps) {
const { checked, icon, text } = props
return (
<div
className={`cursor-pointer select-none flex items-center gap-1 p-1 pr-2 -mb-[1px] border-0 border-b ${
checked
? 'border-primary'
: 'border-chalkboard-20 dark:border-chalkboard-30 hover:bg-primary/20 dark:hover:bg-primary/50'
}`}
>
<CustomIcon
name={icon}
className={
'w-5 h-5 ' + (checked ? 'bg-primary !text-chalkboard-10' : '')
}
/>
<span>{text}</span>
</div>
)
}

View File

@ -0,0 +1,39 @@
import { RadioGroup } from '@headlessui/react'
import { SettingsTabButton } from './SettingsTabButton'
interface SettingsTabButtonProps {
value: string
onChange: (value: string) => void
showProjectTab: boolean
}
export function SettingsTabs({
value,
onChange,
showProjectTab,
}: SettingsTabButtonProps) {
return (
<RadioGroup
value={value}
onChange={onChange}
className="flex justify-start pl-4 pr-5 gap-5 border-0 border-b border-b-chalkboard-20 dark:border-b-chalkboard-90"
>
<RadioGroup.Option value="user">
{({ checked }) => (
<SettingsTabButton checked={checked} icon="person" text="User" />
)}
</RadioGroup.Option>
{showProjectTab && (
<RadioGroup.Option value="project">
{({ checked }) => (
<SettingsTabButton
checked={checked}
icon="folder"
text="This project"
/>
)}
</RadioGroup.Option>
)}
</RadioGroup>
)
}

View File

@ -1,7 +1,13 @@
.toggle { .toggle {
@apply flex items-center gap-2 w-fit; @apply flex items-center gap-2 w-fit;
--toggle-size: 1.25rem; @apply text-chalkboard-110;
--toggle-size: 0.75rem;
--padding: 0.25rem; --padding: 0.25rem;
--border: 1px;
}
:global(.dark) .toggle {
@apply text-chalkboard-10;
} }
.toggle:focus-within > span { .toggle:focus-within > span {
@ -13,9 +19,12 @@
} }
.toggle > span { .toggle > span {
@apply relative rounded border border-chalkboard-110 hover:border-chalkboard-100 cursor-pointer; @apply relative rounded border border-chalkboard-70 hover:border-chalkboard-80 cursor-pointer;
width: calc(2 * (var(--toggle-size) + var(--padding))); border-width: var(--border);
height: calc(var(--toggle-size) + var(--padding)); width: calc(
2 * (var(--toggle-size) + var(--padding) * 2 - var(--border) * 2)
);
height: calc(var(--toggle-size) + var(--padding) * 2 - var(--border) * 2);
} }
:global(.dark) .toggle > span { :global(.dark) .toggle > span {
@ -23,18 +32,26 @@
} }
.toggle > span::after { .toggle > span::after {
width: var(--toggle-size);
height: var(--toggle-size);
border-radius: calc(var(--toggle-size) / 8);
content: ''; content: '';
@apply absolute w-4 h-4 rounded-sm bg-chalkboard-110; @apply absolute bg-chalkboard-70;
top: 50%; top: 50%;
left: 50%; left: 50%;
translate: calc(-100% - var(--padding)) -50%; translate: calc(-100% - var(--padding) + var(--border)) -50%;
transition: translate 0.08s ease-out; transition: translate 0.08s ease-out;
} }
:global(.dark) .toggle > span::after { :global(.dark) .toggle > span::after {
@apply bg-chalkboard-10; @apply bg-chalkboard-50;
} }
.toggle input:checked + span::after { .toggle input:checked + span::after {
translate: calc(50% - var(--padding)) -50%; translate: calc(50% - var(--padding) + var(--border)) -50%;
@apply bg-chalkboard-110;
}
:global(.dark) .toggle input:checked + span::after {
@apply bg-chalkboard-10;
} }

View File

@ -19,7 +19,11 @@ export const Toggle = ({
}: ToggleProps) => { }: ToggleProps) => {
return ( return (
<label className={`${styles.toggle} ${className}`}> <label className={`${styles.toggle} ${className}`}>
{offLabel} <p
className={checked ? 'text-chalkboard-70 dark:text-chalkboard-50' : ''}
>
{offLabel}
</p>
<input <input
type="checkbox" type="checkbox"
name={name} name={name}
@ -28,7 +32,11 @@ export const Toggle = ({
onChange={onChange} onChange={onChange}
/> />
<span></span> <span></span>
{onLabel} <p
className={!checked ? 'text-chalkboard-70 dark:text-chalkboard-50' : ''}
>
{onLabel}
</p>
</label> </label>
) )
} }

View File

@ -1,13 +1,13 @@
import { OnboardingButtons, useDismiss, useNextClick } from '.' import { OnboardingButtons, useDismiss, useNextClick } from '.'
import { onboardingPaths } from 'routes/Onboarding/paths' import { onboardingPaths } from 'routes/Onboarding/paths'
import { useStore } from '../../useStore' import { useStore } from '../../useStore'
import { SettingsSection } from 'routes/Settings'
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext' import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
import { import {
CameraSystem, CameraSystem,
cameraMouseDragGuards, cameraMouseDragGuards,
cameraSystems, cameraSystems,
} from 'lib/cameraControls' } from 'lib/cameraControls'
import { SettingsSection } from 'components/Settings/SettingsSection'
export default function Units() { export default function Units() {
const { buttonDownInStream } = useStore((s) => ({ const { buttonDownInStream } = useStore((s) => ({

View File

@ -1,7 +1,7 @@
import { faArrowRight, faXmark } from '@fortawesome/free-solid-svg-icons' import { faArrowRight, faXmark } from '@fortawesome/free-solid-svg-icons'
import { type BaseUnit, baseUnitsUnion } from 'lib/settings/settingsTypes' import { type BaseUnit, baseUnitsUnion } from 'lib/settings/settingsTypes'
import { ActionButton } from 'components/ActionButton' import { ActionButton } from 'components/ActionButton'
import { SettingsSection } from '../Settings' import { SettingsSection } from 'components/Settings/SettingsSection'
import { useDismiss, useNextClick } from '.' import { useDismiss, useNextClick } from '.'
import { onboardingPaths } from 'routes/Onboarding/paths' import { onboardingPaths } from 'routes/Onboarding/paths'
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext' import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'

View File

@ -1,11 +1,6 @@
import { ActionButton } from '../components/ActionButton' import { ActionButton } from '../components/ActionButton'
import { import { SetEventTypes, SettingsLevel } from 'lib/settings/settingsTypes'
SetEventTypes, import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'
SettingsLevel,
WildcardSetEvent,
} from 'lib/settings/settingsTypes'
import { Toggle } from 'components/Toggle/Toggle'
import { useLocation, useNavigate } from 'react-router-dom'
import { useHotkeys } from 'react-hotkeys-hook' import { useHotkeys } from 'react-hotkeys-hook'
import { paths } from 'lib/paths' import { paths } from 'lib/paths'
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext' import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
@ -14,27 +9,32 @@ import { createAndOpenNewProject, getSettingsFolderPaths } from 'lib/tauriFS'
import { sep } from '@tauri-apps/api/path' import { sep } from '@tauri-apps/api/path'
import { isTauri } from 'lib/isTauri' import { isTauri } from 'lib/isTauri'
import toast from 'react-hot-toast' import toast from 'react-hot-toast'
import React, { Fragment, useMemo, useRef, useState } from 'react' import { Fragment, useEffect, useRef } from 'react'
import { Setting } from 'lib/settings/initialSettings' import { Setting } from 'lib/settings/initialSettings'
import decamelize from 'decamelize' import decamelize from 'decamelize'
import { Event } from 'xstate' import { Dialog, Transition } from '@headlessui/react'
import { Dialog, RadioGroup, Transition } from '@headlessui/react' import { CustomIcon } from 'components/CustomIcon'
import { CustomIcon, CustomIconName } from 'components/CustomIcon'
import Tooltip from 'components/Tooltip'
import { import {
getSettingInputType,
shouldHideSetting, shouldHideSetting,
shouldShowSettingInput, shouldShowSettingInput,
} from 'lib/settings/settingsUtils' } from 'lib/settings/settingsUtils'
import { getInitialDefaultDir, showInFolder } from 'lib/tauri' import { getInitialDefaultDir, showInFolder } from 'lib/tauri'
import { SettingsSearchBar } from 'components/Settings/SettingsSearchBar'
import { SettingsTabs } from 'components/Settings/SettingsTabs'
import { SettingsSection } from 'components/Settings/SettingsSection'
import { SettingsFieldInput } from 'components/Settings/SettingsFieldInput'
export const APP_VERSION = import.meta.env.PACKAGE_VERSION || 'unknown' export const APP_VERSION = import.meta.env.PACKAGE_VERSION || 'unknown'
export const Settings = () => { export const Settings = () => {
const navigate = useNavigate() const navigate = useNavigate()
const [searchParams, setSearchParams] = useSearchParams()
const close = () => navigate(location.pathname.replace(paths.SETTINGS, '')) const close = () => navigate(location.pathname.replace(paths.SETTINGS, ''))
const location = useLocation() const location = useLocation()
const isFileSettings = location.pathname.includes(paths.FILE) const isFileSettings = location.pathname.includes(paths.FILE)
const searchParamTab =
(searchParams.get('tab') as SettingsLevel) ??
(isFileSettings ? 'project' : 'user')
const projectPath = const projectPath =
isFileSettings && isTauri() isFileSettings && isTauri()
? decodeURI( ? decodeURI(
@ -44,9 +44,7 @@ export const Settings = () => {
.slice(0, decodeURI(location.pathname).lastIndexOf(sep())) .slice(0, decodeURI(location.pathname).lastIndexOf(sep()))
) )
: undefined : undefined
const [settingsLevel, setSettingsLevel] = useState<SettingsLevel>(
isFileSettings ? 'project' : 'user'
)
const scrollRef = useRef<HTMLDivElement>(null) const scrollRef = useRef<HTMLDivElement>(null)
const dotDotSlash = useDotDotSlash() const dotDotSlash = useDotDotSlash()
useHotkeys('esc', () => navigate(dotDotSlash())) useHotkeys('esc', () => navigate(dotDotSlash()))
@ -70,6 +68,20 @@ export const Settings = () => {
} }
} }
// Scroll to the hash on load if it exists
useEffect(() => {
console.log('hash', location.hash)
if (location.hash) {
const element = document.getElementById(location.hash.slice(1))
if (element) {
element.scrollIntoView({ block: 'center', behavior: 'smooth' })
;(
element.querySelector('input, select, textarea') as HTMLInputElement
)?.focus()
}
}
}, [location.hash])
return ( return (
<Transition appear show={true} as={Fragment}> <Transition appear show={true} as={Fragment}>
<Dialog <Dialog
@ -102,42 +114,24 @@ export const Settings = () => {
<Dialog.Panel className="rounded relative mx-auto bg-chalkboard-10 dark:bg-chalkboard-100 border dark:border-chalkboard-70 max-w-3xl w-full max-h-[66vh] shadow-lg flex flex-col gap-8"> <Dialog.Panel className="rounded relative mx-auto bg-chalkboard-10 dark:bg-chalkboard-100 border dark:border-chalkboard-70 max-w-3xl w-full max-h-[66vh] shadow-lg flex flex-col gap-8">
<div className="p-5 pb-0 flex justify-between items-center"> <div className="p-5 pb-0 flex justify-between items-center">
<h1 className="text-2xl font-bold">Settings</h1> <h1 className="text-2xl font-bold">Settings</h1>
<button <div className="flex gap-4 items-start">
onClick={close} <SettingsSearchBar />
className="p-0 m-0 focus:ring-0 focus:outline-none border-none hover:bg-destroy-10 focus:bg-destroy-10 dark:hover:bg-destroy-80/50 dark:focus:bg-destroy-80/50" <button
data-testid="settings-close-button" onClick={close}
> className="p-0 m-0 focus:ring-0 focus:outline-none border-none hover:bg-destroy-10 focus:bg-destroy-10 dark:hover:bg-destroy-80/50 dark:focus:bg-destroy-80/50"
<CustomIcon name="close" className="w-5 h-5" /> data-testid="settings-close-button"
</button> >
<CustomIcon name="close" className="w-5 h-5" />
</button>
</div>
</div> </div>
<RadioGroup <SettingsTabs
value={settingsLevel} value={searchParamTab}
onChange={setSettingsLevel} onChange={(v) => setSearchParams((p) => ({ ...p, tab: v }))}
className="flex justify-start pl-4 pr-5 gap-5 border-0 border-b border-b-chalkboard-20 dark:border-b-chalkboard-90" showProjectTab={isFileSettings}
> />
<RadioGroup.Option value="user">
{({ checked }) => (
<SettingsTabButton
checked={checked}
icon="person"
text="User"
/>
)}
</RadioGroup.Option>
{isFileSettings && (
<RadioGroup.Option value="project">
{({ checked }) => (
<SettingsTabButton
checked={checked}
icon="folder"
text="This project"
/>
)}
</RadioGroup.Option>
)}
</RadioGroup>
<div <div
className="flex-1 grid items-stretch pl-4 pr-5 pb-5 gap-4 overflow-hidden" className="flex-1 grid items-stretch pl-4 pr-5 pb-5 gap-2 overflow-hidden"
style={{ gridTemplateColumns: 'auto 1fr' }} style={{ gridTemplateColumns: 'auto 1fr' }}
> >
<div className="flex w-32 flex-col gap-3 pr-2 py-1 border-0 border-r border-r-chalkboard-20 dark:border-r-chalkboard-90"> <div className="flex w-32 flex-col gap-3 pr-2 py-1 border-0 border-r border-r-chalkboard-20 dark:border-r-chalkboard-90">
@ -146,7 +140,7 @@ export const Settings = () => {
// Filter out categories that don't have any non-hidden settings // Filter out categories that don't have any non-hidden settings
Object.values(categorySettings).some( Object.values(categorySettings).some(
(setting: Setting) => (setting: Setting) =>
!shouldHideSetting(setting, settingsLevel) !shouldHideSetting(setting, searchParamTab)
) )
) )
.map(([category]) => ( .map(([category]) => (
@ -156,7 +150,7 @@ export const Settings = () => {
scrollRef.current scrollRef.current
?.querySelector(`#category-${category}`) ?.querySelector(`#category-${category}`)
?.scrollIntoView({ ?.scrollIntoView({
block: 'nearest', block: 'center',
behavior: 'smooth', behavior: 'smooth',
}) })
} }
@ -170,7 +164,7 @@ export const Settings = () => {
scrollRef.current scrollRef.current
?.querySelector(`#settings-resets`) ?.querySelector(`#settings-resets`)
?.scrollIntoView({ ?.scrollIntoView({
block: 'nearest', block: 'center',
behavior: 'smooth', behavior: 'smooth',
}) })
} }
@ -183,7 +177,7 @@ export const Settings = () => {
scrollRef.current scrollRef.current
?.querySelector(`#settings-about`) ?.querySelector(`#settings-about`)
?.scrollIntoView({ ?.scrollIntoView({
block: 'nearest', block: 'center',
behavior: 'smooth', behavior: 'smooth',
}) })
} }
@ -193,19 +187,19 @@ export const Settings = () => {
</button> </button>
</div> </div>
<div className="relative overflow-y-auto"> <div className="relative overflow-y-auto">
<div ref={scrollRef} className="flex flex-col gap-6 px-2"> <div ref={scrollRef} className="flex flex-col gap-4 px-2">
{Object.entries(context) {Object.entries(context)
.filter(([_, categorySettings]) => .filter(([_, categorySettings]) =>
// Filter out categories that don't have any non-hidden settings // Filter out categories that don't have any non-hidden settings
Object.values(categorySettings).some( Object.values(categorySettings).some(
(setting) => !shouldHideSetting(setting, settingsLevel) (setting) => !shouldHideSetting(setting, searchParamTab)
) )
) )
.map(([category, categorySettings]) => ( .map(([category, categorySettings]) => (
<Fragment key={category}> <Fragment key={category}>
<h2 <h2
id={`category-${category}`} id={`category-${category}`}
className="text-2xl mt-6 first-of-type:mt-0 capitalize font-bold" className="text-xl mt-6 first-of-type:mt-0 capitalize font-bold"
> >
{decamelize(category, { separator: ' ' })} {decamelize(category, { separator: ' ' })}
</h2> </h2>
@ -214,44 +208,50 @@ export const Settings = () => {
// Filter out settings that don't have a Component or inputType // Filter out settings that don't have a Component or inputType
// or are hidden on the current level or the current platform // or are hidden on the current level or the current platform
(item: [string, Setting<unknown>]) => (item: [string, Setting<unknown>]) =>
shouldShowSettingInput(item[1], settingsLevel) shouldShowSettingInput(item[1], searchParamTab)
) )
.map(([settingName, s]) => { .map(([settingName, s]) => {
const setting = s as Setting const setting = s as Setting
const parentValue = const parentValue =
setting[setting.getParentLevel(settingsLevel)] setting[setting.getParentLevel(searchParamTab)]
return ( return (
<SettingsSection <SettingsSection
title={decamelize(settingName, { title={decamelize(settingName, {
separator: ' ', separator: ' ',
})} })}
key={`${category}-${settingName}-${settingsLevel}`} id={settingName}
className={
location.hash === `#${settingName}`
? 'bg-primary/10 dark:bg-chalkboard-90'
: ''
}
key={`${category}-${settingName}-${searchParamTab}`}
description={setting.description} description={setting.description}
settingHasChanged={ settingHasChanged={
setting[settingsLevel] !== undefined && setting[searchParamTab] !== undefined &&
setting[settingsLevel] !== setting[searchParamTab] !==
setting.getFallback(settingsLevel) setting.getFallback(searchParamTab)
} }
parentLevel={setting.getParentLevel( parentLevel={setting.getParentLevel(
settingsLevel searchParamTab
)} )}
onFallback={() => onFallback={() =>
send({ send({
type: `set.${category}.${settingName}`, type: `set.${category}.${settingName}`,
data: { data: {
level: settingsLevel, level: searchParamTab,
value: value:
parentValue !== undefined parentValue !== undefined
? parentValue ? parentValue
: setting.getFallback(settingsLevel), : setting.getFallback(searchParamTab),
}, },
} as SetEventTypes) } as SetEventTypes)
} }
> >
<GeneratedSetting <SettingsFieldInput
category={category} category={category}
settingName={settingName} settingName={settingName}
settingsLevel={settingsLevel} settingsLevel={searchParamTab}
setting={setting} setting={setting}
/> />
</SettingsSection> </SettingsSection>
@ -298,7 +298,7 @@ export const Settings = () => {
? decodeURIComponent(projectPath) ? decodeURIComponent(projectPath)
: undefined : undefined
) )
showInFolder(paths[settingsLevel]) showInFolder(paths[searchParamTab])
}} }}
icon={{ icon={{
icon: 'folder', icon: 'folder',
@ -368,226 +368,3 @@ export const Settings = () => {
</Transition> </Transition>
) )
} }
interface SettingsSectionProps extends React.PropsWithChildren {
title: string
description?: string
className?: string
parentLevel?: SettingsLevel | 'default'
onFallback?: () => void
settingHasChanged?: boolean
headingClassName?: string
}
export function SettingsSection({
title,
description,
className,
children,
parentLevel,
settingHasChanged,
onFallback,
headingClassName = 'text-base font-normal capitalize tracking-wide',
}: SettingsSectionProps) {
return (
<section
className={
'group grid grid-cols-2 gap-6 items-start ' +
className +
(settingHasChanged ? ' border-0 border-l-2 -ml-0.5 border-primary' : '')
}
>
<div className="ml-2">
<div className="flex items-center gap-2">
<h2 className={headingClassName}>{title}</h2>
{onFallback && parentLevel && settingHasChanged && (
<button
onClick={onFallback}
className="hidden group-hover:block group-focus-within:block border-none p-0 hover:bg-warn-10 dark:hover:bg-warn-80 focus:bg-warn-10 dark:focus:bg-warn-80 focus:outline-none"
>
<CustomIcon name="refresh" className="w-4 h-4" />
<span className="sr-only">Roll back {title}</span>
<Tooltip position="right">
Roll back to match {parentLevel}
</Tooltip>
</button>
)}
</div>
{description && (
<p className="mt-2 text-xs text-chalkboard-80 dark:text-chalkboard-30">
{description}
</p>
)}
</div>
<div>{children}</div>
</section>
)
}
interface GeneratedSettingProps {
// We don't need the fancy types here,
// it doesn't help us with autocomplete or anything
category: string
settingName: string
settingsLevel: SettingsLevel
setting: Setting<unknown>
}
function GeneratedSetting({
category,
settingName,
settingsLevel,
setting,
}: GeneratedSettingProps) {
const {
settings: { context, send },
} = useSettingsAuthContext()
const options = useMemo(() => {
return setting.commandConfig &&
'options' in setting.commandConfig &&
setting.commandConfig.options
? setting.commandConfig.options instanceof Array
? setting.commandConfig.options
: setting.commandConfig.options(
{
argumentsToSubmit: {
level: settingsLevel,
},
},
context
)
: []
}, [setting, settingsLevel, context])
const inputType = getSettingInputType(setting)
switch (inputType) {
case 'component':
return (
setting.Component && (
<setting.Component
value={setting[settingsLevel] || setting.getFallback(settingsLevel)}
updateValue={(newValue) => {
send({
type: `set.${category}.${settingName}`,
data: {
level: settingsLevel,
value: newValue,
},
} as unknown as Event<WildcardSetEvent>)
}}
/>
)
)
case 'boolean':
return (
<Toggle
offLabel="Off"
onLabel="On"
onChange={(e) =>
send({
type: `set.${category}.${settingName}`,
data: {
level: settingsLevel,
value: Boolean(e.target.checked),
},
} as SetEventTypes)
}
checked={Boolean(
setting[settingsLevel] !== undefined
? setting[settingsLevel]
: setting.getFallback(settingsLevel)
)}
name={`${category}-${settingName}`}
data-testid={`${category}-${settingName}`}
/>
)
case 'options':
return (
<select
name={`${category}-${settingName}`}
data-testid={`${category}-${settingName}`}
className="p-1 bg-transparent border rounded-sm border-chalkboard-30 w-full"
value={String(
setting[settingsLevel] || setting.getFallback(settingsLevel)
)}
onChange={(e) =>
send({
type: `set.${category}.${settingName}`,
data: {
level: settingsLevel,
value: e.target.value,
},
} as unknown as Event<WildcardSetEvent>)
}
>
{options &&
options.length > 0 &&
options.map((option) => (
<option key={option.name} value={String(option.value)}>
{option.name}
</option>
))}
</select>
)
case 'string':
return (
<input
name={`${category}-${settingName}`}
data-testid={`${category}-${settingName}`}
type="text"
className="p-1 bg-transparent border rounded-sm border-chalkboard-30 w-full"
defaultValue={String(
setting[settingsLevel] || setting.getFallback(settingsLevel)
)}
onBlur={(e) => {
if (
setting[settingsLevel] === undefined
? setting.getFallback(settingsLevel) !== e.target.value
: setting[settingsLevel] !== e.target.value
) {
send({
type: `set.${category}.${settingName}`,
data: {
level: settingsLevel,
value: e.target.value,
},
} as unknown as Event<WildcardSetEvent>)
}
}}
/>
)
}
return (
<p className="text-destroy-70 dark:text-destroy-20">
No component or input type found for setting {settingName} in category{' '}
{category}
</p>
)
}
interface SettingsTabButtonProps {
checked: boolean
icon: CustomIconName
text: string
}
function SettingsTabButton(props: SettingsTabButtonProps) {
const { checked, icon, text } = props
return (
<div
className={`cursor-pointer select-none flex items-center gap-1 p-1 pr-2 -mb-[1px] border-0 border-b ${
checked
? 'border-primary'
: 'border-chalkboard-20 dark:border-chalkboard-30 hover:bg-primary/20 dark:hover:bg-primary/50'
}`}
>
<CustomIcon
name={icon}
className={
'w-5 h-5 ' + (checked ? 'bg-primary !text-chalkboard-10' : '')
}
/>
<span>{text}</span>
</div>
)
}