@terrafirmagreg-team/ui 0.1.0 → 1.2.1

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 (157) hide show
  1. package/package.json +14 -14
  2. package/src/components/blocks/button/icon-button.tsx +1 -0
  3. package/src/components/blocks/canvas/chevron-edge.tsx +126 -18
  4. package/src/components/blocks/canvas/flow-canvas.tsx +4 -0
  5. package/src/components/blocks/canvas/flow-toolbar.tsx +1 -0
  6. package/src/components/blocks/canvas/flow-viewport.tsx +1 -0
  7. package/src/components/blocks/fit-box.tsx +26 -0
  8. package/src/components/blocks/git/github-info.tsx +1 -0
  9. package/src/components/blocks/lang/content.tsx +40 -10
  10. package/src/components/blocks/lang/flag.tsx +1 -0
  11. package/src/components/blocks/lang/link.tsx +1 -0
  12. package/src/components/blocks/lang/redirect.tsx +2 -1
  13. package/src/components/blocks/lang/switcher.tsx +1 -0
  14. package/src/components/blocks/lang/untranslated-callout.tsx +4 -3
  15. package/src/components/blocks/lang/untranslated-chip.tsx +53 -0
  16. package/src/components/blocks/link/link-preview.tsx +2 -1
  17. package/src/components/blocks/list/row.tsx +1 -0
  18. package/src/components/blocks/media/glb/model.tsx +1 -0
  19. package/src/components/blocks/media/glb/scene.tsx +13 -12
  20. package/src/components/blocks/media/glb/viewer.tsx +1 -0
  21. package/src/components/blocks/media/image.tsx +3 -2
  22. package/src/components/blocks/media/slideshow.tsx +4 -3
  23. package/src/components/blocks/nav/breadcrumb.tsx +1 -0
  24. package/src/components/blocks/nav/copy-menu.tsx +37 -0
  25. package/src/components/blocks/nav/link.tsx +1 -0
  26. package/src/components/blocks/nav/site-menu.tsx +1 -0
  27. package/src/components/blocks/nav/site-title.tsx +1 -0
  28. package/src/components/blocks/nav/switcher.tsx +1 -0
  29. package/src/components/blocks/page/empty-state.tsx +1 -0
  30. package/src/components/blocks/page/heading.tsx +3 -2
  31. package/src/components/blocks/page/pager.tsx +110 -2
  32. package/src/components/blocks/readability/fields.tsx +250 -0
  33. package/src/components/blocks/readability/menu.tsx +74 -258
  34. package/src/components/blocks/readability/track.tsx +2 -1
  35. package/src/components/blocks/search/command.tsx +1 -0
  36. package/src/components/blocks/search/dialog.tsx +21 -9
  37. package/src/components/blocks/search/provider.tsx +2 -1
  38. package/src/components/blocks/sidebar.tsx +5 -5
  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 +4 -0
  42. package/src/components/blocks/tooltip/hint.tsx +1 -0
  43. package/src/components/blocks/tooltip/lazy.tsx +41 -0
  44. package/src/components/blocks/viewer/controls.tsx +11 -6
  45. package/src/components/blocks/viewer/float-bar.tsx +1 -0
  46. package/src/components/layouts/layout.tsx +2 -1
  47. package/src/components/layouts/left-sidebar/content.tsx +1 -0
  48. package/src/components/layouts/left-sidebar/context.tsx +1 -0
  49. package/src/components/layouts/left-sidebar/expand.tsx +2 -1
  50. package/src/components/layouts/left-sidebar/footer.tsx +10 -4
  51. package/src/components/layouts/left-sidebar/index.ts +1 -0
  52. package/src/components/layouts/left-sidebar/items.tsx +2 -1
  53. package/src/components/layouts/left-sidebar/search.tsx +4 -3
  54. package/src/components/layouts/left-sidebar/sidebar.tsx +3 -2
  55. package/src/components/layouts/left-sidebar/tree.tsx +1 -0
  56. package/src/components/layouts/right-sidebar.tsx +1 -0
  57. package/src/components/layouts/tree-context.tsx +1 -0
  58. package/src/components/mdx/accordion.tsx +3 -2
  59. package/src/components/mdx/banner.tsx +15 -12
  60. package/src/components/mdx/callout.tsx +1 -0
  61. package/src/components/mdx/card.tsx +12 -5
  62. package/src/components/mdx/codeblock.tsx +5 -4
  63. package/src/components/mdx/collapsible.tsx +1 -0
  64. package/src/components/mdx/files.tsx +1 -0
  65. package/src/components/mdx/glow-header.tsx +17 -3
  66. package/src/components/mdx/gradient-text.tsx +1 -0
  67. package/src/components/mdx/icon.tsx +35 -0
  68. package/src/components/mdx/image-zoom.tsx +5 -4
  69. package/src/components/mdx/math.tsx +30 -0
  70. package/src/components/mdx/mermaid.tsx +8 -1
  71. package/src/components/mdx/multiblock.tsx +1 -0
  72. package/src/components/mdx/preview.tsx +2 -1
  73. package/src/components/mdx/steps.tsx +1 -0
  74. package/src/components/mdx/table.tsx +1 -0
  75. package/src/components/mdx/tabs.tsx +2 -1
  76. package/src/components/mdx/type-table.tsx +7 -6
  77. package/src/{provider.tsx → components/provider.tsx} +1 -3
  78. package/src/hooks/canvas/use-flow-hover.ts +1 -0
  79. package/src/hooks/canvas/use-flow-viewport-persist.ts +1 -0
  80. package/src/hooks/page/use-fit-pages.ts +1 -0
  81. package/src/hooks/page/use-location-search.ts +8 -2
  82. package/src/hooks/page/use-page-preview.ts +2 -1
  83. package/src/hooks/page/use-paged-list.ts +1 -0
  84. package/src/hooks/readability/{provider.ts → use-readability.ts} +1 -0
  85. package/src/hooks/search/{dialog.ts → use-dialog.ts} +1 -0
  86. package/src/hooks/search/use-highlight-query.ts +8 -1
  87. package/src/hooks/search/{provider.ts → use-provider.ts} +1 -0
  88. package/src/hooks/use-async-value.ts +6 -3
  89. package/src/hooks/use-close-on-scroll.ts +1 -0
  90. package/src/hooks/use-copy-button.ts +1 -0
  91. package/src/hooks/use-cycle-index.ts +1 -0
  92. package/src/hooks/use-document-title.ts +1 -0
  93. package/src/hooks/use-element-size.ts +1 -0
  94. package/src/hooks/use-external-meta.ts +2 -1
  95. package/src/hooks/use-hot-key.ts +1 -0
  96. package/src/hooks/use-hover-follow.ts +1 -0
  97. package/src/hooks/use-in-view.ts +1 -0
  98. package/src/hooks/{lang.ts → use-lang.ts} +1 -0
  99. package/src/hooks/use-last-value.ts +1 -0
  100. package/src/hooks/{layout.ts → use-layout.ts} +1 -0
  101. package/src/hooks/use-marquee.ts +1 -0
  102. package/src/hooks/use-media-query.ts +1 -0
  103. package/src/hooks/use-mobile.ts +1 -0
  104. package/src/hooks/use-modifier-keys.ts +1 -0
  105. package/src/hooks/use-on-change.ts +1 -0
  106. package/src/hooks/use-pane-keys.ts +1 -0
  107. package/src/hooks/{sidebar.ts → use-sidebar.ts} +1 -0
  108. package/src/hooks/use-swipe.ts +1 -0
  109. package/src/hooks/use-tree.ts +1 -3
  110. package/src/hooks/viewer/use-in-float-bar.ts +1 -0
  111. package/src/hooks/viewer/use-viewer-prefs.ts +1 -0
  112. package/src/lib/canvas/edge.ts +78 -49
  113. package/src/lib/canvas/geometry.ts +1 -0
  114. package/src/lib/canvas/state.ts +1 -0
  115. package/src/lib/canvas/viewport.ts +1 -0
  116. package/src/lib/canvas/xyflow.ts +1 -0
  117. package/src/lib/config.ts +25 -0
  118. package/src/lib/mdx/callout.ts +1 -0
  119. package/src/lib/mdx/gradient.ts +1 -0
  120. package/src/lib/mdx/mermaid.ts +1 -0
  121. package/src/lib/media/crop-opaque.ts +1 -0
  122. package/src/lib/media/nearby-indexes.ts +1 -0
  123. package/src/lib/media/trim-transparent.ts +1 -0
  124. package/src/lib/readability/config.ts +1 -0
  125. package/src/lib/readability/spotlight.ts +1 -0
  126. package/src/lib/readability/store.ts +1 -0
  127. package/src/lib/tree.ts +1 -3
  128. package/src/lib/utils/cn.ts +1 -0
  129. package/src/lib/utils/format.ts +3 -3
  130. package/src/lib/utils/highlight-style.ts +10 -0
  131. package/src/lib/utils/history.ts +1 -0
  132. package/src/lib/utils/merge-refs.ts +1 -0
  133. package/src/lib/utils/nav-active.ts +1 -0
  134. package/src/lib/utils/stored-value.ts +1 -0
  135. package/src/lib/utils/urls.ts +2 -1
  136. package/src/locales/en_us.json +108 -117
  137. package/src/locales/pl_pl.json +33 -35
  138. package/src/locales/ru_ru.json +108 -117
  139. package/src/locales/uk_ua.json +33 -35
  140. package/src/locales/zh_cn.json +15 -17
  141. package/src/locales/zh_tw.json +15 -17
  142. package/src/locales.ts +1 -0
  143. package/src/types/canvas.ts +6 -1
  144. package/src/types/glb.ts +1 -0
  145. package/src/types/mdx-directives.ts +106 -0
  146. package/src/types/nav.ts +1 -0
  147. package/src/types/preview.ts +7 -1
  148. package/src/types/readability.ts +1 -0
  149. package/src/types/search.ts +1 -0
  150. package/src/types/tree.ts +1 -0
  151. package/src/types/viewer.ts +1 -0
  152. package/styles/content/base.css +198 -194
  153. package/styles/content/shiki.css +107 -108
  154. package/styles/content/typography.mjs +3637 -3941
  155. package/styles/content.css +5 -7
  156. package/styles.css +430 -413
  157. package/src/lib/utils/async-cache.ts +0 -32
@@ -89,13 +89,13 @@ export function MediaImage({
89
89
  return (
90
90
  <div
91
91
  role="img"
92
- aria-label={alt || t('ui.image_unavailable')}
92
+ aria-label={alt || t('Image unavailable', { note: 'ui.image_unavailable' })}
93
93
  className={cn(
94
94
  'flex min-h-24 w-full items-center justify-center rounded-md border border-border bg-secondary text-sm text-muted-foreground',
95
95
  className,
96
96
  )}
97
97
  >
98
- {t('ui.image_unavailable')}
98
+ {t('Image unavailable', { note: 'ui.image_unavailable' })}
99
99
  </div>
100
100
  );
101
101
  }
@@ -133,3 +133,4 @@ export function MediaImage({
133
133
  </a>
134
134
  );
135
135
  }
136
+
@@ -137,12 +137,12 @@ export function Slideshow({
137
137
  }
138
138
  expand={{ target: surfaceRef }}
139
139
  settings={{
140
- label: t('ui.viewer.settings'),
140
+ label: t('Viewer settings', { note: 'ui.viewer.settings' }),
141
141
  sliders: multi
142
142
  ? [
143
143
  {
144
144
  icon: <Timer />,
145
- label: t('ui.viewer.autoplay_speed'),
145
+ label: t('Autoplay speed', { note: 'ui.viewer.autoplay_speed' }),
146
146
  min: AUTOPLAY_SPEED_MIN,
147
147
  max: AUTOPLAY_SPEED_MAX,
148
148
  step: 0.5,
@@ -157,7 +157,7 @@ export function Slideshow({
157
157
  ? [
158
158
  {
159
159
  icon: <Play />,
160
- label: t('ui.viewer.autoplay'),
160
+ label: t('Autoplay', { note: 'ui.viewer.autoplay' }),
161
161
  checked: autoplay,
162
162
  onChange: setAutoplay,
163
163
  },
@@ -175,3 +175,4 @@ export function Slideshow({
175
175
  </figure>
176
176
  );
177
177
  }
178
+
@@ -48,3 +48,4 @@ export function Breadcrumb({
48
48
  </BreadcrumbRoot>
49
49
  );
50
50
  }
51
+
@@ -0,0 +1,37 @@
1
+ 'use client';
2
+
3
+ import type { ReactNode } from 'react';
4
+ import {
5
+ ContextMenu,
6
+ ContextMenuContent,
7
+ ContextMenuItem,
8
+ ContextMenuTrigger,
9
+ } from '@terrafirmagreg-team/ui/components/ui/context-menu';
10
+
11
+ export function CopyMenu({
12
+ items,
13
+ extra,
14
+ children,
15
+ }: {
16
+ items: { label: ReactNode; value: string; icon?: ReactNode }[];
17
+ extra?: ReactNode;
18
+ children: ReactNode;
19
+ }) {
20
+ const copy = (value: string) => () => {
21
+ void navigator.clipboard.writeText(value);
22
+ };
23
+ return (
24
+ <ContextMenu>
25
+ <ContextMenuTrigger className="contents">{children}</ContextMenuTrigger>
26
+ <ContextMenuContent>
27
+ {items.map((item, index) => (
28
+ <ContextMenuItem key={index} onClick={copy(item.value)}>
29
+ {item.icon}
30
+ {item.label}
31
+ </ContextMenuItem>
32
+ ))}
33
+ {extra}
34
+ </ContextMenuContent>
35
+ </ContextMenu>
36
+ );
37
+ }
@@ -83,3 +83,4 @@ export function LinkItem({
83
83
  </Link>
84
84
  );
85
85
  }
86
+
@@ -75,3 +75,4 @@ export function SiteMenu({
75
75
  </DropdownMenu>
76
76
  );
77
77
  }
78
+
@@ -58,3 +58,4 @@ export function SiteTitle({
58
58
  </span>
59
59
  );
60
60
  }
61
+
@@ -203,3 +203,4 @@ function Marquee({
203
203
  </span>
204
204
  );
205
205
  }
206
+
@@ -29,3 +29,4 @@ export function EmptyState({
29
29
  </Empty>
30
30
  );
31
31
  }
32
+
@@ -44,10 +44,11 @@ export function Heading({
44
44
  {isChecked ? <CopyCheckIcon /> : <LinkIcon />}
45
45
  <span className="sr-only">
46
46
  {isChecked
47
- ? t('ui.copied_anchor', )
48
- : t('ui.copy_anchor', )}
47
+ ? t('Copied Anchor Link', { note: 'ui.copied_anchor' })
48
+ : t('Copy Anchor Link', { note: 'ui.copy_anchor' })}
49
49
  </span>
50
50
  </button>
51
51
  </As>
52
52
  );
53
53
  }
54
+
@@ -1,15 +1,23 @@
1
1
  'use client';
2
2
 
3
3
  import { ChevronLeft, ChevronRight } from 'lucide-react';
4
- import type { MouseEvent, ReactNode } from 'react';
4
+ import { useRef, useState, type MouseEvent, type ReactNode } from 'react';
5
5
  import { IconButton } from '@terrafirmagreg-team/ui/components/blocks/button/icon-button';
6
+ import { SPRITE_CHIP_CLASS } from '@terrafirmagreg-team/ui/components/blocks/sprite';
6
7
  import {
7
8
  Pagination,
8
9
  PaginationContent,
9
10
  PaginationItem,
10
11
  } from '@terrafirmagreg-team/ui/components/ui/pagination';
11
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';
12
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';
13
21
  import { cn } from '@terrafirmagreg-team/ui/lib/utils/cn';
14
22
 
15
23
  export function PagerArrow({
@@ -34,7 +42,12 @@ export function PagerArrow({
34
42
  const t = useTranslations();
35
43
  return (
36
44
  <IconButton
37
- label={label ?? t(dir < 0 ? 'ui.prev_page' : 'ui.next_page')}
45
+ label={
46
+ label ??
47
+ (dir < 0
48
+ ? t('Previous Page', { note: 'ui.prev_page' })
49
+ : t('Next Page', { note: 'ui.next_page' }))
50
+ }
38
51
  tooltip={false}
39
52
  variant={variant}
40
53
  size={size}
@@ -110,3 +123,98 @@ export function Pager({
110
123
  </Pagination>
111
124
  );
112
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
+