@terrafirmagreg-team/ui 0.0.0-stage → 1.2.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.
Files changed (225) hide show
  1. package/package.json +93 -3
  2. package/src/components/blocks/button/icon-button.tsx +49 -0
  3. package/src/components/blocks/canvas/chevron-edge.tsx +242 -0
  4. package/src/components/blocks/canvas/flow-canvas.tsx +181 -0
  5. package/src/components/blocks/canvas/flow-toolbar.tsx +33 -0
  6. package/src/components/blocks/canvas/flow-viewport.tsx +244 -0
  7. package/src/components/blocks/fit-box.tsx +26 -0
  8. package/src/components/blocks/git/github-info.tsx +78 -0
  9. package/src/components/blocks/lang/content.tsx +52 -0
  10. package/src/components/blocks/lang/flag.tsx +25 -0
  11. package/src/components/blocks/lang/link.tsx +23 -0
  12. package/src/components/blocks/lang/redirect.tsx +48 -0
  13. package/src/components/blocks/lang/switcher.tsx +54 -0
  14. package/src/components/blocks/lang/untranslated-callout.tsx +29 -0
  15. package/src/components/blocks/lang/untranslated-chip.tsx +53 -0
  16. package/src/components/blocks/link/link-preview.tsx +257 -0
  17. package/src/components/blocks/list/row.tsx +92 -0
  18. package/src/components/blocks/media/glb/model.tsx +244 -0
  19. package/src/components/blocks/media/glb/scene.tsx +373 -0
  20. package/src/components/blocks/media/glb/viewer.tsx +52 -0
  21. package/src/components/blocks/media/image.tsx +136 -0
  22. package/src/components/blocks/media/slideshow.tsx +178 -0
  23. package/src/components/blocks/nav/breadcrumb.tsx +51 -0
  24. package/src/components/blocks/nav/copy-menu.tsx +37 -0
  25. package/src/components/blocks/nav/link.tsx +86 -0
  26. package/src/components/blocks/nav/site-menu.tsx +78 -0
  27. package/src/components/blocks/nav/site-title.tsx +61 -0
  28. package/src/components/blocks/nav/switcher.tsx +206 -0
  29. package/src/components/blocks/page/empty-state.tsx +32 -0
  30. package/src/components/blocks/page/heading.tsx +54 -0
  31. package/src/components/blocks/page/pager.tsx +220 -0
  32. package/src/components/blocks/readability/fields.tsx +250 -0
  33. package/src/components/blocks/readability/menu.tsx +247 -0
  34. package/src/components/blocks/readability/track.tsx +165 -0
  35. package/src/components/blocks/search/command.tsx +80 -0
  36. package/src/components/blocks/search/dialog.tsx +724 -0
  37. package/src/components/blocks/search/provider.tsx +118 -0
  38. package/src/components/blocks/sidebar.tsx +406 -0
  39. package/src/components/blocks/sprite.tsx +231 -0
  40. package/src/components/blocks/text/format-text.tsx +110 -0
  41. package/src/components/blocks/theme/toggle.tsx +75 -0
  42. package/src/components/blocks/tooltip/hint.tsx +28 -0
  43. package/src/components/blocks/tooltip/lazy.tsx +41 -0
  44. package/src/components/blocks/viewer/controls.tsx +284 -0
  45. package/src/components/blocks/viewer/float-bar.tsx +51 -0
  46. package/src/components/layouts/layout.tsx +94 -0
  47. package/src/components/layouts/left-sidebar/content.tsx +63 -0
  48. package/src/components/layouts/left-sidebar/context.tsx +55 -0
  49. package/src/components/layouts/left-sidebar/expand.tsx +23 -0
  50. package/src/components/layouts/left-sidebar/footer.tsx +111 -0
  51. package/src/components/layouts/left-sidebar/index.ts +29 -0
  52. package/src/components/layouts/left-sidebar/items.tsx +103 -0
  53. package/src/components/layouts/left-sidebar/search.tsx +75 -0
  54. package/src/components/layouts/left-sidebar/sidebar.tsx +189 -0
  55. package/src/components/layouts/left-sidebar/tree.tsx +157 -0
  56. package/src/components/layouts/right-sidebar.tsx +96 -0
  57. package/src/components/layouts/tree-context.tsx +42 -0
  58. package/src/components/mdx/accordion.tsx +185 -0
  59. package/src/components/mdx/banner.tsx +157 -0
  60. package/src/components/mdx/callout.tsx +97 -0
  61. package/src/components/mdx/card.tsx +56 -0
  62. package/src/components/mdx/codeblock.tsx +250 -0
  63. package/src/components/mdx/collapsible.tsx +33 -0
  64. package/src/components/mdx/files.tsx +63 -0
  65. package/src/components/mdx/glow-header.tsx +54 -0
  66. package/src/components/mdx/gradient-text.tsx +28 -0
  67. package/src/components/mdx/icon.tsx +35 -0
  68. package/src/components/mdx/image-zoom.tsx +315 -0
  69. package/src/components/mdx/math.tsx +30 -0
  70. package/src/components/mdx/mermaid.tsx +23 -0
  71. package/src/components/mdx/multiblock.tsx +19 -0
  72. package/src/components/mdx/preview.tsx +101 -0
  73. package/src/components/mdx/steps.tsx +10 -0
  74. package/src/components/mdx/table.tsx +14 -0
  75. package/src/components/mdx/tabs.tsx +354 -0
  76. package/src/components/mdx/type-table.tsx +171 -0
  77. package/src/components/provider.tsx +139 -0
  78. package/src/components/ui/accordion.tsx +71 -0
  79. package/src/components/ui/alert-dialog.tsx +187 -0
  80. package/src/components/ui/alert.tsx +75 -0
  81. package/src/components/ui/aspect-ratio.tsx +22 -0
  82. package/src/components/ui/attachment.tsx +207 -0
  83. package/src/components/ui/avatar.tsx +108 -0
  84. package/src/components/ui/badge.tsx +51 -0
  85. package/src/components/ui/breadcrumb.tsx +124 -0
  86. package/src/components/ui/bubble.tsx +127 -0
  87. package/src/components/ui/button-group.tsx +87 -0
  88. package/src/components/ui/button.tsx +57 -0
  89. package/src/components/ui/calendar.tsx +221 -0
  90. package/src/components/ui/card.tsx +102 -0
  91. package/src/components/ui/carousel.tsx +242 -0
  92. package/src/components/ui/chart.tsx +371 -0
  93. package/src/components/ui/checkbox.tsx +28 -0
  94. package/src/components/ui/collapsible.tsx +21 -0
  95. package/src/components/ui/combobox.tsx +297 -0
  96. package/src/components/ui/command.tsx +198 -0
  97. package/src/components/ui/context-menu.tsx +270 -0
  98. package/src/components/ui/dialog.tsx +160 -0
  99. package/src/components/ui/direction.tsx +6 -0
  100. package/src/components/ui/drawer.tsx +227 -0
  101. package/src/components/ui/dropdown-menu.tsx +269 -0
  102. package/src/components/ui/empty.tsx +103 -0
  103. package/src/components/ui/field.tsx +238 -0
  104. package/src/components/ui/hover-card.tsx +50 -0
  105. package/src/components/ui/input-group.tsx +158 -0
  106. package/src/components/ui/input-otp.tsx +86 -0
  107. package/src/components/ui/input.tsx +19 -0
  108. package/src/components/ui/item.tsx +201 -0
  109. package/src/components/ui/kbd.tsx +26 -0
  110. package/src/components/ui/label.tsx +19 -0
  111. package/src/components/ui/marker.tsx +70 -0
  112. package/src/components/ui/menubar.tsx +280 -0
  113. package/src/components/ui/message-scroller.tsx +131 -0
  114. package/src/components/ui/message.tsx +91 -0
  115. package/src/components/ui/native-select.tsx +60 -0
  116. package/src/components/ui/navigation-menu.tsx +167 -0
  117. package/src/components/ui/pagination.tsx +130 -0
  118. package/src/components/ui/popover.tsx +91 -0
  119. package/src/components/ui/progress.tsx +82 -0
  120. package/src/components/ui/questionnaire.tsx +324 -0
  121. package/src/components/ui/radio-group.tsx +37 -0
  122. package/src/components/ui/resizable.tsx +49 -0
  123. package/src/components/ui/scroll-area.tsx +54 -0
  124. package/src/components/ui/select.tsx +200 -0
  125. package/src/components/ui/separator.tsx +24 -0
  126. package/src/components/ui/sheet.tsx +138 -0
  127. package/src/components/ui/sidebar.tsx +730 -0
  128. package/src/components/ui/skeleton.tsx +13 -0
  129. package/src/components/ui/slider.tsx +51 -0
  130. package/src/components/ui/spinner.tsx +10 -0
  131. package/src/components/ui/switch.tsx +31 -0
  132. package/src/components/ui/table.tsx +115 -0
  133. package/src/components/ui/tabs.tsx +81 -0
  134. package/src/components/ui/textarea.tsx +17 -0
  135. package/src/components/ui/toast.tsx +234 -0
  136. package/src/components/ui/toggle-group.tsx +89 -0
  137. package/src/components/ui/toggle.tsx +44 -0
  138. package/src/components/ui/tooltip.tsx +65 -0
  139. package/src/hooks/canvas/use-flow-hover.ts +19 -0
  140. package/src/hooks/canvas/use-flow-viewport-persist.ts +34 -0
  141. package/src/hooks/page/use-fit-pages.ts +92 -0
  142. package/src/hooks/page/use-location-search.ts +31 -0
  143. package/src/hooks/page/use-page-preview.ts +41 -0
  144. package/src/hooks/page/use-paged-list.ts +21 -0
  145. package/src/hooks/readability/use-readability.ts +87 -0
  146. package/src/hooks/search/use-dialog.ts +42 -0
  147. package/src/hooks/search/use-highlight-query.ts +58 -0
  148. package/src/hooks/search/use-provider.ts +26 -0
  149. package/src/hooks/use-async-value.ts +60 -0
  150. package/src/hooks/use-close-on-scroll.ts +24 -0
  151. package/src/hooks/use-copy-button.ts +47 -0
  152. package/src/hooks/use-cycle-index.ts +76 -0
  153. package/src/hooks/use-document-title.ts +10 -0
  154. package/src/hooks/use-element-size.ts +36 -0
  155. package/src/hooks/use-external-meta.ts +133 -0
  156. package/src/hooks/use-hot-key.ts +34 -0
  157. package/src/hooks/use-hover-follow.ts +55 -0
  158. package/src/hooks/use-in-view.ts +31 -0
  159. package/src/hooks/use-lang.ts +9 -0
  160. package/src/hooks/use-last-value.ts +16 -0
  161. package/src/hooks/use-layout.ts +19 -0
  162. package/src/hooks/use-marquee.ts +52 -0
  163. package/src/hooks/use-media-query.ts +15 -0
  164. package/src/hooks/use-mobile.ts +20 -0
  165. package/src/hooks/use-modifier-keys.ts +32 -0
  166. package/src/hooks/use-on-change.ts +36 -0
  167. package/src/hooks/use-pane-keys.ts +29 -0
  168. package/src/hooks/use-sidebar.ts +64 -0
  169. package/src/hooks/use-swipe.ts +27 -0
  170. package/src/hooks/use-tree.ts +100 -0
  171. package/src/hooks/viewer/use-in-float-bar.ts +7 -0
  172. package/src/hooks/viewer/use-viewer-prefs.ts +78 -0
  173. package/src/lib/canvas/edge.ts +202 -0
  174. package/src/lib/canvas/geometry.ts +54 -0
  175. package/src/lib/canvas/state.ts +48 -0
  176. package/src/lib/canvas/viewport.ts +38 -0
  177. package/src/lib/canvas/xyflow.ts +2 -0
  178. package/src/lib/config.ts +106 -0
  179. package/src/lib/mdx/callout.ts +21 -0
  180. package/src/lib/mdx/gradient.ts +16 -0
  181. package/src/lib/mdx/mermaid.ts +15 -0
  182. package/src/lib/media/crop-opaque.ts +77 -0
  183. package/src/lib/media/nearby-indexes.ts +7 -0
  184. package/src/lib/media/trim-transparent.ts +118 -0
  185. package/src/lib/readability/config.ts +11 -0
  186. package/src/lib/readability/spotlight.ts +187 -0
  187. package/src/lib/readability/store.ts +147 -0
  188. package/src/lib/tree.ts +90 -0
  189. package/src/lib/utils/cn.ts +2 -0
  190. package/src/lib/utils/format.ts +22 -0
  191. package/src/lib/utils/highlight-style.ts +10 -0
  192. package/src/lib/utils/history.ts +10 -0
  193. package/src/lib/utils/merge-refs.ts +14 -0
  194. package/src/lib/utils/nav-active.ts +59 -0
  195. package/src/lib/utils/stored-value.ts +137 -0
  196. package/src/lib/utils/urls.ts +18 -0
  197. package/src/locales/en_us.json +110 -0
  198. package/src/locales/pl_pl.json +33 -0
  199. package/src/locales/ru_ru.json +110 -0
  200. package/src/locales/uk_ua.json +33 -0
  201. package/src/locales/zh_cn.json +15 -0
  202. package/src/locales/zh_tw.json +15 -0
  203. package/src/locales.ts +6 -0
  204. package/src/types/canvas.ts +54 -0
  205. package/src/types/glb.ts +26 -0
  206. package/src/types/mdx-directives.ts +106 -0
  207. package/src/types/nav.ts +96 -0
  208. package/src/types/preview.ts +15 -0
  209. package/src/types/readability.ts +29 -0
  210. package/src/types/search.ts +57 -0
  211. package/src/types/tree.ts +19 -0
  212. package/src/types/viewer.ts +54 -0
  213. package/styles/content/base.css +198 -0
  214. package/styles/content/default-colors.css +51 -0
  215. package/styles/content/shiki.css +107 -0
  216. package/styles/content/typography.mjs +3637 -0
  217. package/styles/content.css +5 -0
  218. package/styles/image-zoom.css +83 -0
  219. package/styles/layout.css +28 -0
  220. package/styles/left-sidebar.css +140 -0
  221. package/styles/readability.css +50 -0
  222. package/styles/right-sidebar.css +6 -0
  223. package/styles/workspace.css +26 -0
  224. package/styles.css +430 -0
  225. package/README.md +0 -3
@@ -0,0 +1,32 @@
1
+ import { FileQuestion } from 'lucide-react';
2
+ import type { ReactNode } from 'react';
3
+ import {
4
+ Empty,
5
+ EmptyDescription,
6
+ EmptyMedia,
7
+ } from '@terrafirmagreg-team/ui/components/ui/empty';
8
+ import { Spinner } from '@terrafirmagreg-team/ui/components/ui/spinner';
9
+
10
+ export function EmptyState({
11
+ variant = 'empty',
12
+ children,
13
+ }: {
14
+ variant?: 'empty' | 'missing' | 'loading';
15
+ children?: ReactNode;
16
+ }) {
17
+ return (
18
+ <Empty className="gap-1 rounded-none border-none p-0 text-left">
19
+ {variant === 'missing' ? (
20
+ <EmptyMedia variant="icon" className="mb-0">
21
+ <FileQuestion />
22
+ </EmptyMedia>
23
+ ) : variant === 'loading' ? (
24
+ <EmptyMedia variant="icon" className="mb-0">
25
+ <Spinner />
26
+ </EmptyMedia>
27
+ ) : null}
28
+ <EmptyDescription className="text-left">{children}</EmptyDescription>
29
+ </Empty>
30
+ );
31
+ }
32
+
@@ -0,0 +1,54 @@
1
+ 'use client';
2
+ import { CopyCheckIcon, LinkIcon } from 'lucide-react';
3
+ import type { ComponentProps } from 'react';
4
+ import { cn } from '@terrafirmagreg-team/ui/lib/utils/cn';
5
+ import { buttonVariants } from '@terrafirmagreg-team/ui/components/ui/button';
6
+ import { useCopyButton } from '@terrafirmagreg-team/ui/hooks/use-copy-button';
7
+ import { useTranslations } from '@terrafirmagreg-team/translate/hooks/use-translations';
8
+
9
+ type HeadingTag = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
10
+
11
+ export function Heading({
12
+ as,
13
+ ...props
14
+ }: ComponentProps<'h1'> & { as?: HeadingTag }) {
15
+ const As = as ?? 'h1';
16
+ const t = useTranslations();
17
+ const { checked: isChecked, onClick: onCopy } = useCopyButton(() => {
18
+ if (!props.id) return;
19
+ const url = new URL(window.location.href);
20
+ url.hash = props.id;
21
+ return navigator.clipboard.writeText(url.href);
22
+ });
23
+
24
+ if (!props.id) return <As {...props} />;
25
+ return (
26
+ <As
27
+ {...props}
28
+ className={cn(
29
+ 'group/heading flex scroll-m-28 flex-row items-center gap-1',
30
+ props.className,
31
+ )}
32
+ >
33
+ <a data-card href={`#${props.id}`}>
34
+ {props.children}
35
+ </a>
36
+ <button
37
+ aria-live="polite"
38
+ className={cn(
39
+ buttonVariants({ variant: 'ghost', size: 'icon-xs' }),
40
+ 'not-prose shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover/heading:opacity-100',
41
+ )}
42
+ onClick={onCopy}
43
+ >
44
+ {isChecked ? <CopyCheckIcon /> : <LinkIcon />}
45
+ <span className="sr-only">
46
+ {isChecked
47
+ ? t('Copied Anchor Link', { note: 'ui.copied_anchor' })
48
+ : t('Copy Anchor Link', { note: 'ui.copy_anchor' })}
49
+ </span>
50
+ </button>
51
+ </As>
52
+ );
53
+ }
54
+
@@ -0,0 +1,220 @@
1
+ 'use client';
2
+
3
+ import { ChevronLeft, ChevronRight } from 'lucide-react';
4
+ import { useRef, useState, type MouseEvent, type ReactNode } from 'react';
5
+ import { IconButton } from '@terrafirmagreg-team/ui/components/blocks/button/icon-button';
6
+ import { SPRITE_CHIP_CLASS } from '@terrafirmagreg-team/ui/components/blocks/sprite';
7
+ import {
8
+ Pagination,
9
+ PaginationContent,
10
+ PaginationItem,
11
+ } from '@terrafirmagreg-team/ui/components/ui/pagination';
12
+ import { Progress } from '@terrafirmagreg-team/ui/components/ui/progress';
13
+ import {
14
+ Tooltip,
15
+ TooltipContent,
16
+ TooltipTrigger,
17
+ } from '@terrafirmagreg-team/ui/components/ui/tooltip';
18
+ import { useTranslations } from '@terrafirmagreg-team/translate/hooks/use-translations';
19
+ import { useElementSize } from '@terrafirmagreg-team/ui/hooks/use-element-size';
20
+ import { useSwipe } from '@terrafirmagreg-team/ui/hooks/use-swipe';
21
+ import { cn } from '@terrafirmagreg-team/ui/lib/utils/cn';
22
+
23
+ export function PagerArrow({
24
+ dir,
25
+ disabled,
26
+ onClick,
27
+ label,
28
+ variant = 'secondary',
29
+ size = 'icon-lg',
30
+ overlay,
31
+ className,
32
+ }: {
33
+ dir: -1 | 1;
34
+ disabled?: boolean;
35
+ onClick: (event: MouseEvent<HTMLButtonElement>) => void;
36
+ label?: string;
37
+ variant?: 'outline' | 'secondary' | 'ghost';
38
+ size?: 'icon-xs' | 'icon-sm' | 'icon-lg';
39
+ overlay?: boolean;
40
+ className?: string;
41
+ }) {
42
+ const t = useTranslations();
43
+ return (
44
+ <IconButton
45
+ label={
46
+ label ??
47
+ (dir < 0
48
+ ? t('Previous Page', { note: 'ui.prev_page' })
49
+ : t('Next Page', { note: 'ui.next_page' }))
50
+ }
51
+ tooltip={false}
52
+ variant={variant}
53
+ size={size}
54
+ overlay={overlay}
55
+ disabled={disabled}
56
+ onClick={onClick}
57
+ className={cn(
58
+ 'shrink-0',
59
+ variant === 'secondary' &&
60
+ 'border-foreground/10 hover:bg-background dark:hover:bg-accent',
61
+ className,
62
+ )}
63
+ >
64
+ {dir < 0 ? (
65
+ <ChevronLeft className="size-[58%]" />
66
+ ) : (
67
+ <ChevronRight className="size-[58%]" />
68
+ )}
69
+ </IconButton>
70
+ );
71
+ }
72
+
73
+ export function Pager({
74
+ page,
75
+ pages,
76
+ to,
77
+ total,
78
+ onPage,
79
+ label,
80
+ leading,
81
+ className,
82
+ }: {
83
+ page: number;
84
+ pages: number;
85
+ to: number;
86
+ total: number;
87
+ onPage: (page: number) => void;
88
+ label: string;
89
+ leading?: ReactNode;
90
+ className?: string;
91
+ }) {
92
+ return (
93
+ <Pagination className={cn('mx-0 w-full', className)}>
94
+ <PaginationContent className="w-full justify-between gap-2">
95
+ <PaginationItem className="flex items-center gap-1">
96
+ <PagerArrow
97
+ dir={-1}
98
+ disabled={page <= 0}
99
+ onClick={(event) => onPage(event.shiftKey ? 0 : page - 1)}
100
+ />
101
+ {leading}
102
+ </PaginationItem>
103
+ <PaginationItem className="flex min-w-0 items-center gap-2 text-xs text-muted-foreground">
104
+ <Progress
105
+ value={to}
106
+ max={Math.max(total, 1)}
107
+ className="w-14 shrink-0 gap-0 sm:w-20"
108
+ />
109
+ <span className="shrink-0 tabular-nums">
110
+ {label
111
+ .replaceAll('{page}', String(page + 1))
112
+ .replaceAll('{pages}', String(pages))}
113
+ </span>
114
+ </PaginationItem>
115
+ <PaginationItem>
116
+ <PagerArrow
117
+ dir={1}
118
+ disabled={page + 1 >= pages}
119
+ onClick={(event) => onPage(event.shiftKey ? pages - 1 : page + 1)}
120
+ />
121
+ </PaginationItem>
122
+ </PaginationContent>
123
+ </Pagination>
124
+ );
125
+ }
126
+
127
+ type IconPagerOption<T extends string> = {
128
+ id: T;
129
+ icon: ReactNode;
130
+ help?: string;
131
+ };
132
+
133
+ const PAGER_CELL = 40;
134
+ const PAGER_GAP = 4;
135
+ const PAGER_ARROW = 36;
136
+
137
+ export function IconPager<T extends string>({
138
+ value,
139
+ options,
140
+ onChange,
141
+ className,
142
+ }: {
143
+ value?: T;
144
+ options: IconPagerOption<T>[];
145
+ onChange: (id: T) => void;
146
+ className?: string;
147
+ }) {
148
+ const stripRef = useRef<HTMLDivElement | null>(null);
149
+ const { w } = useElementSize(stripRef);
150
+ const per = Math.max(
151
+ 1,
152
+ Math.floor((w - 2 * (PAGER_ARROW + PAGER_GAP) + PAGER_GAP) / PAGER_CELL),
153
+ );
154
+ const pages = Math.max(1, Math.ceil(options.length / per));
155
+ const valueIndex = options.findIndex((option) => option.id === value);
156
+ const autoPage = Math.max(0, Math.floor(valueIndex / per));
157
+ const [manual, setManual] = useState<number | null>(null);
158
+ const [lastValue, setLastValue] = useState(value);
159
+ if (lastValue !== value) {
160
+ setLastValue(value);
161
+ setManual(null);
162
+ }
163
+ const page = Math.min(manual ?? autoPage, pages - 1);
164
+ const shown = options.slice(page * per, (page + 1) * per);
165
+
166
+ const go = (dir: number) => setManual((page + dir + pages) % pages);
167
+ const swipe = useSwipe((dir) => {
168
+ const next = valueIndex + dir;
169
+ if (next >= 0 && next < options.length) {
170
+ onChange(options[next]!.id);
171
+ } else {
172
+ go(dir);
173
+ }
174
+ });
175
+
176
+ return (
177
+ <div
178
+ ref={stripRef}
179
+ {...swipe}
180
+ onWheel={(event) => {
181
+ if (pages > 1) go(event.deltaY > 0 ? 1 : -1);
182
+ }}
183
+ className={cn('flex min-h-9 items-center gap-1', className)}
184
+ >
185
+ <PagerArrow dir={-1} disabled={pages <= 1} onClick={() => go(-1)} />
186
+ <div
187
+ className={cn(
188
+ 'flex min-w-0 flex-1 items-center justify-center gap-1',
189
+ w === 0 && 'invisible',
190
+ )}
191
+ >
192
+ {shown.map((option) => (
193
+ <Tooltip key={option.id}>
194
+ <TooltipTrigger
195
+ render={
196
+ <button
197
+ type="button"
198
+ onClick={() => onChange(option.id)}
199
+ aria-label={option.help ?? option.id}
200
+ aria-pressed={option.id === value}
201
+ className={cn(
202
+ SPRITE_CHIP_CLASS,
203
+ 'size-9 rounded-sm p-0 text-muted-foreground outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring aria-pressed:border-primary aria-pressed:bg-accent aria-pressed:text-foreground',
204
+ )}
205
+ />
206
+ }
207
+ >
208
+ {option.icon}
209
+ </TooltipTrigger>
210
+ {option.help ? (
211
+ <TooltipContent>{option.help}</TooltipContent>
212
+ ) : null}
213
+ </Tooltip>
214
+ ))}
215
+ </div>
216
+ <PagerArrow dir={1} disabled={pages <= 1} onClick={() => go(1)} />
217
+ </div>
218
+ );
219
+ }
220
+
@@ -0,0 +1,250 @@
1
+ 'use client';
2
+
3
+ import { useState, type ReactNode } from 'react';
4
+ import { CircleHelp } from 'lucide-react';
5
+ import { cn } from '@terrafirmagreg-team/ui/lib/utils/cn';
6
+ import { useTranslations } from '@terrafirmagreg-team/translate/hooks/use-translations';
7
+ import {
8
+ Popover,
9
+ PopoverContent,
10
+ PopoverTrigger,
11
+ } from '@terrafirmagreg-team/ui/components/ui/popover';
12
+ import { IconButton } from '@terrafirmagreg-team/ui/components/blocks/button/icon-button';
13
+ import { Hint } from '@terrafirmagreg-team/ui/components/blocks/tooltip/hint';
14
+ import {
15
+ Field,
16
+ FieldContent,
17
+ FieldDescription,
18
+ FieldLabel,
19
+ } from '@terrafirmagreg-team/ui/components/ui/field';
20
+ import { Slider } from '@terrafirmagreg-team/ui/components/ui/slider';
21
+ import {
22
+ ToggleGroup,
23
+ ToggleGroupItem,
24
+ } from '@terrafirmagreg-team/ui/components/ui/toggle-group';
25
+
26
+ export function ReadabilityHelp({
27
+ title,
28
+ description,
29
+ items,
30
+ }: {
31
+ title: string;
32
+ description?: string;
33
+ items?: { icon?: ReactNode; label: string; help: string }[];
34
+ }) {
35
+ return (
36
+ <div className="space-y-2">
37
+ <div className="space-y-1">
38
+ <p className="font-medium">{title}</p>
39
+ {description && <FieldDescription className="text-xs">{description}</FieldDescription>}
40
+ </div>
41
+ {items?.map((item) => (
42
+ <div key={item.label} className="space-y-1 rounded-lg bg-muted/60 p-2.5">
43
+ <p className="flex items-center gap-1.5 text-xs font-medium [&_svg:not([class*='size-'])]:size-3.5 [&_svg]:shrink-0">
44
+ {item.icon}
45
+ {item.label}
46
+ </p>
47
+ <FieldDescription className="text-xs">{item.help}</FieldDescription>
48
+ </div>
49
+ ))}
50
+ </div>
51
+ );
52
+ }
53
+
54
+ export function ReadabilityField({
55
+ label,
56
+ icon,
57
+ help,
58
+ children,
59
+ }: {
60
+ label: ReactNode;
61
+ icon?: ReactNode;
62
+ help?: ReactNode;
63
+ children: ReactNode;
64
+ }) {
65
+ const [helpOpen, setHelpOpen] = useState(false);
66
+ return (
67
+ <Field className="gap-1.5">
68
+ <div className="flex items-center gap-1 [&_svg:not([class*='size-'])]:size-3.5 [&_svg]:shrink-0">
69
+ {icon}
70
+ <FieldLabel className="flex-1 text-xs text-muted-foreground">{label}</FieldLabel>
71
+ {help && (
72
+ <Popover onOpenChange={setHelpOpen}>
73
+ <PopoverTrigger
74
+ render={
75
+ <IconButton
76
+ variant="ghost"
77
+ size="icon-xs"
78
+ tooltip={false}
79
+ label={typeof label === 'string' ? label : 'Help'}
80
+ >
81
+ <CircleHelp />
82
+ </IconButton>
83
+ }
84
+ />
85
+ <PopoverContent className="w-56">{help}</PopoverContent>
86
+ </Popover>
87
+ )}
88
+ </div>
89
+ <FieldContent
90
+ className={cn(
91
+ 'rounded-lg outline-2 outline-offset-4 outline-dashed outline-transparent transition-[outline-color] duration-200',
92
+ helpOpen && 'outline-primary',
93
+ )}
94
+ >
95
+ {children}
96
+ </FieldContent>
97
+ </Field>
98
+ );
99
+ }
100
+
101
+ export function ReadabilitySlider({
102
+ label,
103
+ icon,
104
+ help,
105
+ value,
106
+ onChange,
107
+ min = 0,
108
+ max = 100,
109
+ step = 1,
110
+ format = String,
111
+ disabled,
112
+ live,
113
+ }: {
114
+ label: ReactNode;
115
+ icon?: ReactNode;
116
+ help?: ReactNode;
117
+ value: number;
118
+ onChange: (value: number) => void;
119
+ min?: number;
120
+ max?: number;
121
+ step?: number;
122
+ format?: (value: number) => string;
123
+ disabled?: boolean;
124
+ live?: boolean;
125
+ }) {
126
+ const [draft, setDraft] = useState<{ key: number; value: number }>({ key: value, value });
127
+ const shown = draft.key === value ? draft.value : value;
128
+ return (
129
+ <ReadabilityField label={label} icon={icon} help={help}>
130
+ <div className="flex items-center gap-3 rounded-lg bg-muted/70 px-3 py-2.5">
131
+ <Slider
132
+ min={min}
133
+ max={max}
134
+ step={step}
135
+ value={shown}
136
+ disabled={disabled}
137
+ className="flex-1 [&_[data-slot=slider-track]]:bg-muted-foreground/25"
138
+ onValueChange={(next) => {
139
+ if (live) onChange(next as number);
140
+ else setDraft({ key: value, value: next as number });
141
+ }}
142
+ onValueCommitted={() => {
143
+ if (!live && shown !== value) onChange(shown);
144
+ }}
145
+ />
146
+ <span className="w-9 shrink-0 text-end text-xs tabular-nums text-muted-foreground">
147
+ {format(shown)}
148
+ </span>
149
+ </div>
150
+ </ReadabilityField>
151
+ );
152
+ }
153
+
154
+ export function Segment<T extends string>({
155
+ options,
156
+ value,
157
+ onChange,
158
+ disabled,
159
+ itemContent,
160
+ itemLabel,
161
+ itemHelp,
162
+ itemClassName,
163
+ }: {
164
+ options: readonly T[];
165
+ value: T;
166
+ onChange: (value: T) => void;
167
+ disabled?: boolean;
168
+ itemContent: (id: T) => ReactNode;
169
+ itemLabel: (id: T) => string;
170
+ itemHelp: (id: T) => string;
171
+ itemClassName?: string;
172
+ }) {
173
+ return (
174
+ <ToggleGroup
175
+ value={[value]}
176
+ onValueChange={(values) => {
177
+ const next = values.at(-1) as T | undefined;
178
+ if (next && next !== value) onChange(next);
179
+ }}
180
+ disabled={disabled}
181
+ size="sm"
182
+ spacing={1}
183
+ className="w-full rounded-lg bg-muted/70 p-1"
184
+ >
185
+ {options.map((id) => (
186
+ <Hint key={id} label={itemHelp(id)}>
187
+ <ToggleGroupItem
188
+ value={id}
189
+ aria-label={itemLabel(id)}
190
+ className={cn(
191
+ 'flex-1 rounded-md text-muted-foreground hover:text-foreground aria-pressed:bg-background aria-pressed:text-foreground aria-pressed:shadow-sm',
192
+ itemClassName,
193
+ )}
194
+ >
195
+ {itemContent(id)}
196
+ </ToggleGroupItem>
197
+ </Hint>
198
+ ))}
199
+ </ToggleGroup>
200
+ );
201
+ }
202
+
203
+ export function WidthField({
204
+ field,
205
+ icon,
206
+ value,
207
+ onChange,
208
+ }: {
209
+ field: 'page_width' | 'content_width';
210
+ icon: ReactNode;
211
+ value: number;
212
+ onChange: (value: number) => void;
213
+ }) {
214
+ const t = useTranslations();
215
+ const w = {
216
+ page_width: {
217
+ label: t('Page width', { note: 'ui.readability.page_width' }),
218
+ help: t('Adjust the maximum width of the page layout to fit different reading needs and screens', { note: 'ui.readability.page_width.help' }),
219
+ slider: t('Page width slider', { note: 'ui.readability.page_width.slider' }),
220
+ sliderHelp: t('A ranged slider to choose the maximum width of the page layout', { note: 'ui.readability.page_width.slider.help' }),
221
+ },
222
+ content_width: {
223
+ label: t('Content width', { note: 'ui.readability.content_width' }),
224
+ help: t('Adjust the maximum width of the text column to fit different reading needs and screens', { note: 'ui.readability.content_width.help' }),
225
+ slider: t('Content width slider', { note: 'ui.readability.content_width.slider' }),
226
+ sliderHelp: t('A ranged slider to choose the maximum width of the content column', { note: 'ui.readability.content_width.slider.help' }),
227
+ },
228
+ }[field];
229
+ return (
230
+ <ReadabilitySlider
231
+ live
232
+ label={w.label}
233
+ icon={icon}
234
+ help={
235
+ <ReadabilityHelp
236
+ title={w.label}
237
+ description={w.help}
238
+ items={[{ icon, label: w.slider, help: w.sliderHelp }]}
239
+ />
240
+ }
241
+ value={value}
242
+ onChange={onChange}
243
+ min={60}
244
+ max={100}
245
+ step={1}
246
+ format={(v) => `${v}%`}
247
+ />
248
+ );
249
+ }
250
+