config-drawer.tsx 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362
  1. import { type SVGProps } from 'react'
  2. import { Root as Radio, Item } from '@radix-ui/react-radio-group'
  3. import { CircleCheck, RotateCcw, Settings } from 'lucide-react'
  4. import { IconDir } from '@/assets/custom/icon-dir'
  5. import { IconLayoutCompact } from '@/assets/custom/icon-layout-compact'
  6. import { IconLayoutDefault } from '@/assets/custom/icon-layout-default'
  7. import { IconLayoutFull } from '@/assets/custom/icon-layout-full'
  8. import { IconSidebarFloating } from '@/assets/custom/icon-sidebar-floating'
  9. import { IconSidebarInset } from '@/assets/custom/icon-sidebar-inset'
  10. import { IconSidebarSidebar } from '@/assets/custom/icon-sidebar-sidebar'
  11. import { IconThemeDark } from '@/assets/custom/icon-theme-dark'
  12. import { IconThemeLight } from '@/assets/custom/icon-theme-light'
  13. import { IconThemeSystem } from '@/assets/custom/icon-theme-system'
  14. import { cn } from '@/lib/utils'
  15. import { useDirection } from '@/context/direction-provider'
  16. import { type Collapsible, useLayout } from '@/context/layout-provider'
  17. import { useTheme } from '@/context/theme-provider'
  18. import { Button } from '@/components/ui/button'
  19. import {
  20. Sheet,
  21. SheetContent,
  22. SheetDescription,
  23. SheetFooter,
  24. SheetHeader,
  25. SheetTitle,
  26. SheetTrigger,
  27. } from '@/components/ui/sheet'
  28. import { useSidebar } from './ui/sidebar'
  29. export function ConfigDrawer() {
  30. const { setOpen } = useSidebar()
  31. const { resetDir } = useDirection()
  32. const { resetTheme } = useTheme()
  33. const { resetLayout } = useLayout()
  34. const handleReset = () => {
  35. setOpen(true)
  36. resetDir()
  37. resetTheme()
  38. resetLayout()
  39. }
  40. return (
  41. <Sheet>
  42. <SheetTrigger asChild>
  43. <Button
  44. size='icon'
  45. variant='ghost'
  46. aria-label='Open theme settings'
  47. className='rounded-full'
  48. >
  49. <Settings aria-hidden='true' />
  50. </Button>
  51. </SheetTrigger>
  52. <SheetContent className='flex flex-col'>
  53. <SheetHeader className='pb-0 text-start'>
  54. <SheetTitle>Theme Settings</SheetTitle>
  55. <SheetDescription>
  56. Adjust the appearance and layout to suit your preferences.
  57. </SheetDescription>
  58. </SheetHeader>
  59. <div className='space-y-6 overflow-y-auto px-4'>
  60. <ThemeConfig />
  61. <SidebarConfig />
  62. <LayoutConfig />
  63. <DirConfig />
  64. </div>
  65. <SheetFooter className='gap-2'>
  66. <Button
  67. variant='destructive'
  68. onClick={handleReset}
  69. aria-label='Reset all settings to default values'
  70. >
  71. Reset
  72. </Button>
  73. </SheetFooter>
  74. </SheetContent>
  75. </Sheet>
  76. )
  77. }
  78. function SectionTitle({
  79. title,
  80. showReset = false,
  81. onReset,
  82. resetAriaLabel,
  83. className,
  84. }: {
  85. title: string
  86. showReset?: boolean
  87. onReset?: () => void
  88. /** Shown on the small per-section reset (RotateCcw) for accessibility and tests. */
  89. resetAriaLabel?: string
  90. className?: string
  91. }) {
  92. return (
  93. <div
  94. className={cn(
  95. 'mb-2 flex items-center gap-2 text-sm font-semibold text-muted-foreground',
  96. className
  97. )}
  98. >
  99. {title}
  100. {showReset && onReset && (
  101. <Button
  102. type='button'
  103. size='icon'
  104. variant='secondary'
  105. className='size-4 rounded-full'
  106. onClick={onReset}
  107. aria-label={resetAriaLabel}
  108. >
  109. <RotateCcw className='size-3' />
  110. </Button>
  111. )}
  112. </div>
  113. )
  114. }
  115. function RadioGroupItem({
  116. item,
  117. isTheme = false,
  118. }: {
  119. item: {
  120. value: string
  121. label: string
  122. icon: (props: SVGProps<SVGSVGElement>) => React.ReactElement
  123. }
  124. isTheme?: boolean
  125. }) {
  126. return (
  127. <Item
  128. value={item.value}
  129. className={cn('group outline-none', 'transition duration-200 ease-in')}
  130. aria-label={`Select ${item.label.toLowerCase()}`}
  131. aria-describedby={`${item.value}-description`}
  132. >
  133. <div
  134. className={cn(
  135. 'relative rounded-[6px] ring-[1px] ring-border',
  136. 'group-data-[state=checked]:shadow-2xl group-data-[state=checked]:ring-primary',
  137. 'group-focus-visible:ring-2'
  138. )}
  139. role='img'
  140. aria-hidden='false'
  141. aria-label={`${item.label} option preview`}
  142. >
  143. <CircleCheck
  144. className={cn(
  145. 'size-6 fill-primary stroke-white',
  146. 'group-data-[state=unchecked]:hidden',
  147. 'absolute top-0 right-0 translate-x-1/2 -translate-y-1/2'
  148. )}
  149. aria-hidden='true'
  150. />
  151. <item.icon
  152. className={cn(
  153. !isTheme &&
  154. 'fill-primary stroke-primary group-data-[state=unchecked]:fill-muted-foreground group-data-[state=unchecked]:stroke-muted-foreground'
  155. )}
  156. aria-hidden='true'
  157. />
  158. </div>
  159. <div
  160. className='mt-1 text-xs'
  161. id={`${item.value}-description`}
  162. aria-live='polite'
  163. >
  164. {item.label}
  165. </div>
  166. </Item>
  167. )
  168. }
  169. function ThemeConfig() {
  170. const { defaultTheme, theme, setTheme } = useTheme()
  171. return (
  172. <div>
  173. <SectionTitle
  174. title='Theme'
  175. showReset={theme !== defaultTheme}
  176. onReset={() => setTheme(defaultTheme)}
  177. resetAriaLabel='Reset theme preference to default'
  178. />
  179. <Radio
  180. value={theme}
  181. onValueChange={setTheme}
  182. className='grid w-full max-w-md grid-cols-3 gap-4'
  183. aria-label='Select theme preference'
  184. aria-describedby='theme-description'
  185. >
  186. {[
  187. {
  188. value: 'system',
  189. label: 'System',
  190. icon: IconThemeSystem,
  191. },
  192. {
  193. value: 'light',
  194. label: 'Light',
  195. icon: IconThemeLight,
  196. },
  197. {
  198. value: 'dark',
  199. label: 'Dark',
  200. icon: IconThemeDark,
  201. },
  202. ].map((item) => (
  203. <RadioGroupItem key={item.value} item={item} isTheme />
  204. ))}
  205. </Radio>
  206. <div id='theme-description' className='sr-only'>
  207. Choose between system preference, light mode, or dark mode
  208. </div>
  209. </div>
  210. )
  211. }
  212. function SidebarConfig() {
  213. const { defaultVariant, variant, setVariant } = useLayout()
  214. return (
  215. <div className='max-md:hidden'>
  216. <SectionTitle
  217. title='Sidebar'
  218. showReset={defaultVariant !== variant}
  219. onReset={() => setVariant(defaultVariant)}
  220. resetAriaLabel='Reset sidebar style to default'
  221. />
  222. <Radio
  223. value={variant}
  224. onValueChange={setVariant}
  225. className='grid w-full max-w-md grid-cols-3 gap-4'
  226. aria-label='Select sidebar style'
  227. aria-describedby='sidebar-description'
  228. >
  229. {[
  230. {
  231. value: 'inset',
  232. label: 'Inset',
  233. icon: IconSidebarInset,
  234. },
  235. {
  236. value: 'floating',
  237. label: 'Floating',
  238. icon: IconSidebarFloating,
  239. },
  240. {
  241. value: 'sidebar',
  242. label: 'Sidebar',
  243. icon: IconSidebarSidebar,
  244. },
  245. ].map((item) => (
  246. <RadioGroupItem key={item.value} item={item} />
  247. ))}
  248. </Radio>
  249. <div id='sidebar-description' className='sr-only'>
  250. Choose between inset, floating, or standard sidebar layout
  251. </div>
  252. </div>
  253. )
  254. }
  255. function LayoutConfig() {
  256. const { open, setOpen } = useSidebar()
  257. const { defaultCollapsible, collapsible, setCollapsible } = useLayout()
  258. const radioState = open ? 'default' : collapsible
  259. return (
  260. <div className='max-md:hidden'>
  261. <SectionTitle
  262. title='Layout'
  263. showReset={radioState !== 'default'}
  264. onReset={() => {
  265. setOpen(true)
  266. setCollapsible(defaultCollapsible)
  267. }}
  268. resetAriaLabel='Reset layout options to default'
  269. />
  270. <Radio
  271. value={radioState}
  272. onValueChange={(v) => {
  273. if (v === 'default') {
  274. setOpen(true)
  275. return
  276. }
  277. setOpen(false)
  278. setCollapsible(v as Collapsible)
  279. }}
  280. className='grid w-full max-w-md grid-cols-3 gap-4'
  281. aria-label='Select layout style'
  282. aria-describedby='layout-description'
  283. >
  284. {[
  285. {
  286. value: 'default',
  287. label: 'Default',
  288. icon: IconLayoutDefault,
  289. },
  290. {
  291. value: 'icon',
  292. label: 'Compact',
  293. icon: IconLayoutCompact,
  294. },
  295. {
  296. value: 'offcanvas',
  297. label: 'Full layout',
  298. icon: IconLayoutFull,
  299. },
  300. ].map((item) => (
  301. <RadioGroupItem key={item.value} item={item} />
  302. ))}
  303. </Radio>
  304. <div id='layout-description' className='sr-only'>
  305. Choose between default expanded, compact icon-only, or full layout mode
  306. </div>
  307. </div>
  308. )
  309. }
  310. function DirConfig() {
  311. const { defaultDir, dir, setDir } = useDirection()
  312. return (
  313. <div>
  314. <SectionTitle
  315. title='Direction'
  316. showReset={defaultDir !== dir}
  317. onReset={() => setDir(defaultDir)}
  318. resetAriaLabel='Reset text direction to default'
  319. />
  320. <Radio
  321. value={dir}
  322. onValueChange={setDir}
  323. className='grid w-full max-w-md grid-cols-3 gap-4'
  324. aria-label='Select site direction'
  325. aria-describedby='direction-description'
  326. >
  327. {[
  328. {
  329. value: 'ltr',
  330. label: 'Left to Right',
  331. icon: (props: SVGProps<SVGSVGElement>) => (
  332. <IconDir dir='ltr' {...props} />
  333. ),
  334. },
  335. {
  336. value: 'rtl',
  337. label: 'Right to Left',
  338. icon: (props: SVGProps<SVGSVGElement>) => (
  339. <IconDir dir='rtl' {...props} />
  340. ),
  341. },
  342. ].map((item) => (
  343. <RadioGroupItem key={item.value} item={item} />
  344. ))}
  345. </Radio>
  346. <div id='direction-description' className='sr-only'>
  347. Choose between left-to-right or right-to-left site direction
  348. </div>
  349. </div>
  350. )
  351. }