@prestack/thread-ui 0.1.9 → 0.1.11

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 (210) hide show
  1. package/CHANGELOG.md +366 -0
  2. package/dist/ui/attach.js +6 -7
  3. package/dist/ui/compositions.d.ts +0 -2
  4. package/dist/ui/compositions.js +2 -112
  5. package/dist/ui/generators/routes.js +1 -0
  6. package/dist/ui/variant.d.ts +1 -3
  7. package/package.json +2 -2
  8. package/dist/ui/animations/anm_appear.ts +0 -13
  9. package/dist/ui/animations/anm_blur_in.ts +0 -19
  10. package/dist/ui/animations/anm_blur_out.ts +0 -18
  11. package/dist/ui/animations/anm_dismiss.ts +0 -18
  12. package/dist/ui/animations/anm_drop.ts +0 -19
  13. package/dist/ui/animations/anm_fall.ts +0 -18
  14. package/dist/ui/animations/anm_flash.ts +0 -18
  15. package/dist/ui/animations/anm_glow.ts +0 -16
  16. package/dist/ui/animations/anm_lift.ts +0 -16
  17. package/dist/ui/animations/anm_nudge.ts +0 -16
  18. package/dist/ui/animations/anm_pop_in.ts +0 -23
  19. package/dist/ui/animations/anm_pop_out.ts +0 -22
  20. package/dist/ui/animations/anm_press.ts +0 -12
  21. package/dist/ui/animations/anm_pulse.ts +0 -13
  22. package/dist/ui/animations/anm_rise.ts +0 -19
  23. package/dist/ui/animations/anm_shake.ts +0 -13
  24. package/dist/ui/animations/anm_slide_in_left.ts +0 -19
  25. package/dist/ui/animations/anm_slide_in_right.ts +0 -19
  26. package/dist/ui/animations/anm_slide_out_left.ts +0 -18
  27. package/dist/ui/animations/anm_slide_out_right.ts +0 -18
  28. package/dist/ui/animations/anm_spin.ts +0 -12
  29. package/dist/ui/animations/anm_swell.ts +0 -16
  30. package/dist/ui/animations/anm_wobble.ts +0 -24
  31. package/dist/ui/animations/anm_zoom_in.ts +0 -19
  32. package/dist/ui/animations/anm_zoom_out.ts +0 -18
  33. package/dist/ui/animations.ts +0 -308
  34. package/dist/ui/attach.ts +0 -553
  35. package/dist/ui/charts.tsx +0 -69
  36. package/dist/ui/checked.ts +0 -32
  37. package/dist/ui/collector.ts +0 -140
  38. package/dist/ui/compositions.ts +0 -555
  39. package/dist/ui/consent.ts +0 -55
  40. package/dist/ui/content.tsx +0 -27
  41. package/dist/ui/contrast.ts +0 -27
  42. package/dist/ui/controls.tsx +0 -25
  43. package/dist/ui/disclosure.tsx +0 -20
  44. package/dist/ui/dismiss-layer.ts +0 -87
  45. package/dist/ui/door.ts +0 -140
  46. package/dist/ui/drag.ts +0 -316
  47. package/dist/ui/elements/accordion.tsx +0 -47
  48. package/dist/ui/elements/accordion.works.ts +0 -66
  49. package/dist/ui/elements/aspect-ratio.tsx +0 -31
  50. package/dist/ui/elements/aspect-ratio.works.ts +0 -5
  51. package/dist/ui/elements/avatar.tsx +0 -34
  52. package/dist/ui/elements/avatar.works.ts +0 -16
  53. package/dist/ui/elements/badge.tsx +0 -33
  54. package/dist/ui/elements/badge.works.ts +0 -9
  55. package/dist/ui/elements/bars.tsx +0 -50
  56. package/dist/ui/elements/bars.works.ts +0 -29
  57. package/dist/ui/elements/box.tsx +0 -36
  58. package/dist/ui/elements/breadcrumb.tsx +0 -38
  59. package/dist/ui/elements/breadcrumb.works.ts +0 -24
  60. package/dist/ui/elements/button.tsx +0 -46
  61. package/dist/ui/elements/button.works.tsx +0 -52
  62. package/dist/ui/elements/column.tsx +0 -29
  63. package/dist/ui/elements/consent.tsx +0 -71
  64. package/dist/ui/elements/consent.works.tsx +0 -132
  65. package/dist/ui/elements/donut.tsx +0 -45
  66. package/dist/ui/elements/donut.works.ts +0 -42
  67. package/dist/ui/elements/each.tsx +0 -34
  68. package/dist/ui/elements/each.works.tsx +0 -82
  69. package/dist/ui/elements/empty.tsx +0 -33
  70. package/dist/ui/elements/field.tsx +0 -45
  71. package/dist/ui/elements/field.works.ts +0 -65
  72. package/dist/ui/elements/funnel.tsx +0 -50
  73. package/dist/ui/elements/funnel.works.ts +0 -37
  74. package/dist/ui/elements/gauge.tsx +0 -40
  75. package/dist/ui/elements/gauge.works.ts +0 -42
  76. package/dist/ui/elements/glyph.tsx +0 -30
  77. package/dist/ui/elements/glyph.works.tsx +0 -42
  78. package/dist/ui/elements/grid.tsx +0 -29
  79. package/dist/ui/elements/heatmap.tsx +0 -48
  80. package/dist/ui/elements/heatmap.works.ts +0 -24
  81. package/dist/ui/elements/hit-box.tsx +0 -47
  82. package/dist/ui/elements/hit-box.works.tsx +0 -576
  83. package/dist/ui/elements/image.tsx +0 -29
  84. package/dist/ui/elements/line-chart.tsx +0 -45
  85. package/dist/ui/elements/line-chart.works.ts +0 -41
  86. package/dist/ui/elements/link.tsx +0 -38
  87. package/dist/ui/elements/link.works.ts +0 -25
  88. package/dist/ui/elements/media.tsx +0 -57
  89. package/dist/ui/elements/menu.tsx +0 -51
  90. package/dist/ui/elements/menu.works.tsx +0 -154
  91. package/dist/ui/elements/money.tsx +0 -36
  92. package/dist/ui/elements/money.works.ts +0 -9
  93. package/dist/ui/elements/pagination.tsx +0 -39
  94. package/dist/ui/elements/pagination.works.ts +0 -20
  95. package/dist/ui/elements/progress.tsx +0 -34
  96. package/dist/ui/elements/progress.works.ts +0 -8
  97. package/dist/ui/elements/radio-group.tsx +0 -50
  98. package/dist/ui/elements/radio-group.works.ts +0 -33
  99. package/dist/ui/elements/row.tsx +0 -29
  100. package/dist/ui/elements/scatter.tsx +0 -42
  101. package/dist/ui/elements/scatter.works.ts +0 -38
  102. package/dist/ui/elements/select.tsx +0 -43
  103. package/dist/ui/elements/select.works.ts +0 -5
  104. package/dist/ui/elements/separator.tsx +0 -31
  105. package/dist/ui/elements/skeleton.tsx +0 -31
  106. package/dist/ui/elements/slider.tsx +0 -34
  107. package/dist/ui/elements/slider.works.ts +0 -5
  108. package/dist/ui/elements/sparkline.tsx +0 -37
  109. package/dist/ui/elements/sparkline.works.ts +0 -32
  110. package/dist/ui/elements/spinner.tsx +0 -33
  111. package/dist/ui/elements/stack.tsx +0 -29
  112. package/dist/ui/elements/stat.tsx +0 -36
  113. package/dist/ui/elements/stat.works.ts +0 -14
  114. package/dist/ui/elements/surface.tsx +0 -36
  115. package/dist/ui/elements/surface.works.ts +0 -9
  116. package/dist/ui/elements/table.tsx +0 -62
  117. package/dist/ui/elements/table.works.ts +0 -30
  118. package/dist/ui/elements/tabs.tsx +0 -47
  119. package/dist/ui/elements/tabs.works.ts +0 -84
  120. package/dist/ui/elements/text.tsx +0 -33
  121. package/dist/ui/elements/textarea.tsx +0 -38
  122. package/dist/ui/elements/textarea.works.ts +0 -5
  123. package/dist/ui/elements/time.tsx +0 -40
  124. package/dist/ui/elements/time.works.ts +0 -29
  125. package/dist/ui/elements/toggle.tsx +0 -34
  126. package/dist/ui/elements/toggle.works.ts +0 -5
  127. package/dist/ui/elements/tooltip.tsx +0 -40
  128. package/dist/ui/elements/tooltip.works.ts +0 -77
  129. package/dist/ui/elements/value.tsx +0 -38
  130. package/dist/ui/elements/value.works.ts +0 -9
  131. package/dist/ui/elements.ts +0 -127
  132. package/dist/ui/focus-scope.ts +0 -121
  133. package/dist/ui/fonts.ts +0 -355
  134. package/dist/ui/generators/dev.d.ts +0 -1
  135. package/dist/ui/generators/dev.js +0 -61
  136. package/dist/ui/generators/dev.ts +0 -74
  137. package/dist/ui/generators/elements.ts +0 -796
  138. package/dist/ui/generators/i18n.ts +0 -283
  139. package/dist/ui/generators/icons.ts +0 -207
  140. package/dist/ui/generators/react.ts +0 -267
  141. package/dist/ui/generators/routes.ts +0 -847
  142. package/dist/ui/generators/show.ts +0 -261
  143. package/dist/ui/generators/styles.ts +0 -677
  144. package/dist/ui/generators/variants.ts +0 -196
  145. package/dist/ui/i18n/catalogs/en.ts +0 -191
  146. package/dist/ui/i18n/format.ts +0 -221
  147. package/dist/ui/i18n/locales.ts +0 -33
  148. package/dist/ui/i18n/parse.ts +0 -315
  149. package/dist/ui/i18n/translator.ts +0 -31
  150. package/dist/ui/i18n/types.ts +0 -96
  151. package/dist/ui/icons.ts +0 -331
  152. package/dist/ui/islands/map.ts +0 -58
  153. package/dist/ui/localizations.ts +0 -75
  154. package/dist/ui/material.ts +0 -236
  155. package/dist/ui/materials/mat_bordered.ts +0 -13
  156. package/dist/ui/materials/mat_ceramic.ts +0 -13
  157. package/dist/ui/materials/mat_clay.ts +0 -13
  158. package/dist/ui/materials/mat_elevated.ts +0 -24
  159. package/dist/ui/materials/mat_flat.ts +0 -34
  160. package/dist/ui/materials/mat_glass.ts +0 -20
  161. package/dist/ui/materials/mat_lit.ts +0 -13
  162. package/dist/ui/materials/mat_moulded.ts +0 -11
  163. package/dist/ui/materials.ts +0 -197
  164. package/dist/ui/modes.ts +0 -44
  165. package/dist/ui/navigation.tsx +0 -29
  166. package/dist/ui/positioning.ts +0 -90
  167. package/dist/ui/presented.tsx +0 -119
  168. package/dist/ui/provider.tsx +0 -184
  169. package/dist/ui/render-loop.ts +0 -133
  170. package/dist/ui/roving-focus.ts +0 -161
  171. package/dist/ui/screens.ts +0 -141
  172. package/dist/ui/scroll-lock.ts +0 -28
  173. package/dist/ui/series.ts +0 -97
  174. package/dist/ui/signals.ts +0 -386
  175. package/dist/ui/styleguide.ts +0 -231
  176. package/dist/ui/styleguides.ts +0 -374
  177. package/dist/ui/themes.ts +0 -22
  178. package/dist/ui/token.ts +0 -78
  179. package/dist/ui/tokens/shadow.ts +0 -25
  180. package/dist/ui/tokens.ts +0 -234
  181. package/dist/ui/validators.ts +0 -232
  182. package/dist/ui/variant.ts +0 -352
  183. package/dist/ui/variants/var_avatar-round.ts +0 -14
  184. package/dist/ui/variants/var_bubble-me.ts +0 -14
  185. package/dist/ui/variants/var_bubble-them.ts +0 -13
  186. package/dist/ui/variants/var_button-border.ts +0 -76
  187. package/dist/ui/variants/var_button-cta.ts +0 -76
  188. package/dist/ui/variants/var_button-destructive-ghost.ts +0 -72
  189. package/dist/ui/variants/var_button-destructive.ts +0 -76
  190. package/dist/ui/variants/var_button-fill.ts +0 -76
  191. package/dist/ui/variants/var_button-ghost.ts +0 -72
  192. package/dist/ui/variants/var_button-glass.ts +0 -55
  193. package/dist/ui/variants/var_button-normal.ts +0 -76
  194. package/dist/ui/variants/var_button-quiet.ts +0 -74
  195. package/dist/ui/variants/var_card.ts +0 -17
  196. package/dist/ui/variants/var_chip.ts +0 -13
  197. package/dist/ui/variants/var_choice.ts +0 -15
  198. package/dist/ui/variants/var_framed.ts +0 -10
  199. package/dist/ui/variants/var_glyph-plain.ts +0 -11
  200. package/dist/ui/variants/var_input.ts +0 -43
  201. package/dist/ui/variants/var_link-inline.ts +0 -29
  202. package/dist/ui/variants/var_list-row.ts +0 -34
  203. package/dist/ui/variants/var_rule.ts +0 -10
  204. package/dist/ui/variants/var_skeleton-block.ts +0 -12
  205. package/dist/ui/variants/var_spinner-turning.ts +0 -12
  206. package/dist/ui/variants/var_stat-tile.ts +0 -13
  207. package/dist/ui/variants/var_tab-bar.ts +0 -15
  208. package/dist/ui/variants/var_tap-target.ts +0 -25
  209. package/dist/ui/variants.ts +0 -64
  210. package/dist/ui/wrappers.tsx +0 -429
@@ -1,90 +0,0 @@
1
- 'use client'
2
-
3
- import {
4
- arrow as arrowMiddleware,
5
- computePosition,
6
- flip,
7
- offset,
8
- shift,
9
- size,
10
- type Middleware,
11
- type Placement,
12
- type Platform,
13
- } from '@floating-ui/dom'
14
- import { useFloating, autoUpdate } from '@floating-ui/react-dom'
15
- import type { RefObject } from 'react'
16
-
17
- export interface AnchorOptions {
18
- readonly placement?: Placement
19
-
20
- readonly gap?: number
21
-
22
- readonly padding?: number
23
- readonly arrowRef?: RefObject<HTMLElement | null>
24
- }
25
-
26
- export interface Position {
27
- readonly x: number
28
- readonly y: number
29
-
30
- readonly placement: Placement
31
-
32
- readonly maxWidth?: number
33
- readonly maxHeight?: number
34
- readonly arrow?: { readonly x?: number; readonly y?: number }
35
- }
36
-
37
- const DEFAULT_GAP = 4
38
- const DEFAULT_PADDING = 8
39
-
40
- function middleware(options: AnchorOptions): Middleware[] {
41
- const padding = options.padding ?? DEFAULT_PADDING
42
- const list: Middleware[] = [
43
- offset(options.gap ?? DEFAULT_GAP),
44
- flip({ padding }),
45
- shift({ padding }),
46
- size({
47
- padding,
48
- apply({ availableWidth, availableHeight, elements }) {
49
-
50
- Object.assign(elements.floating.style, {
51
- maxWidth: `${Math.max(0, availableWidth)}px`,
52
- maxHeight: `${Math.max(0, availableHeight)}px`,
53
- overflow: 'auto',
54
- })
55
- },
56
- }),
57
- ]
58
-
59
- if (options.arrowRef !== undefined) {
60
- list.push(arrowMiddleware({ element: options.arrowRef.current as Element, padding }))
61
- }
62
- return list
63
- }
64
-
65
- export async function positionOf(
66
- reference: Element,
67
- floating: HTMLElement,
68
- options: AnchorOptions & { readonly platform?: Platform } = {},
69
- ): Promise<Position> {
70
- const result = await computePosition(reference, floating, {
71
- placement: options.placement ?? 'bottom',
72
- middleware: middleware(options),
73
- ...(options.platform === undefined ? {} : { platform: options.platform }),
74
- })
75
- const arrowData = result.middlewareData.arrow
76
- return {
77
- x: result.x,
78
- y: result.y,
79
- placement: result.placement,
80
- ...(arrowData === undefined ? {} : { arrow: { ...(arrowData.x === undefined ? {} : { x: arrowData.x }), ...(arrowData.y === undefined ? {} : { y: arrowData.y }) } }),
81
- }
82
- }
83
-
84
- export function useAnchored(options: AnchorOptions = {}): ReturnType<typeof useFloating> {
85
- return useFloating({
86
- placement: options.placement ?? 'bottom',
87
- middleware: middleware(options),
88
- whileElementsMounted: autoUpdate,
89
- })
90
- }
@@ -1,119 +0,0 @@
1
- 'use client'
2
-
3
- import { useEffect, useId, useRef, useState, type ReactNode } from 'react'
4
- import { createPortal } from 'react-dom'
5
-
6
- import { PRESENTATION_BEHAVIOUR, type Presentation } from './door.ts'
7
- import { useDismissLayer } from './dismiss-layer.ts'
8
- import { useFocusScope } from './focus-scope.ts'
9
- import { lockScroll } from './scroll-lock.ts'
10
-
11
- export interface PresentedProps {
12
- readonly presentation: Presentation
13
-
14
- readonly open?: boolean
15
- readonly onDismiss?: () => void
16
- readonly className?: string
17
- readonly children: ReactNode
18
- }
19
-
20
- export function Presented(props: PresentedProps): ReactNode {
21
- const behaviour = PRESENTATION_BEHAVIOUR[props.presentation]
22
- const open = props.open ?? true
23
- const ref = useRef<HTMLDivElement>(null)
24
- const [leaving, setLeaving] = useState(false)
25
- const [was, setWas] = useState(open)
26
- const labelId = useId()
27
-
28
- useFocusScope(ref, {
29
- active: open,
30
- trapFocus: behaviour.trapFocus,
31
- returnFocus: behaviour.returnFocus,
32
- })
33
-
34
- useDismissLayer(ref, {
35
- active: open && (behaviour.dismissOnEscape || behaviour.dismissOnOutside),
36
- dismissOnEscape: behaviour.dismissOnEscape,
37
- dismissOnOutside: behaviour.dismissOnOutside,
38
- onDismiss: props.onDismiss ?? (() => undefined),
39
- })
40
-
41
- useEffect(() => {
42
- if (!open || !behaviour.backdrop) return
43
- return lockScroll()
44
- }, [open, behaviour.backdrop])
45
-
46
- if (was !== open) {
47
- setWas(open)
48
- setLeaving(!open)
49
- }
50
-
51
- useEffect(() => {
52
- if (!leaving) return undefined
53
- const node = ref.current
54
- if (node === null) {
55
- setLeaving(false)
56
- return undefined
57
- }
58
- const running =
59
- typeof globalThis.getComputedStyle === 'function' &&
60
- globalThis.getComputedStyle(node).animationName !== 'none'
61
- if (!running) {
62
- setLeaving(false)
63
- return undefined
64
- }
65
- const done = (): void => setLeaving(false)
66
- node.addEventListener('animationend', done)
67
-
68
- const style = globalThis.getComputedStyle(node)
69
- const seconds = (one: string): number =>
70
- one.split(',').reduce((most, part) => Math.max(most, Number.parseFloat(part) || 0), 0)
71
- const after = (seconds(style.animationDuration) + seconds(style.animationDelay)) * 1000 + 50
72
- const timer = setTimeout(done, after)
73
-
74
- return () => {
75
- node.removeEventListener('animationend', done)
76
- clearTimeout(timer)
77
- }
78
- }, [leaving])
79
-
80
- useEffect(() => {
81
- if (!open) return
82
- const root = ref.current
83
- if (root === null) return
84
- const title = root.querySelector('[data-screen-title], h1, h2, h3, h4, h5, h6')
85
- if (title === null) return
86
- if (title.id === '') title.id = labelId
87
- root.setAttribute('aria-labelledby', title.id)
88
- }, [open, labelId])
89
-
90
- if (!open && !leaving) return null
91
-
92
- const surface = (
93
- <div
94
- ref={ref}
95
- className={props.className}
96
- data-presentation={props.presentation}
97
- {...(leaving ? { 'data-leaving': '' } : {})}
98
-
99
- {...(behaviour.trapFocus ? { role: 'dialog', 'aria-modal': true } : {})}
100
- {...(props.presentation === 'toast' ? { role: 'status', 'aria-live': 'polite' as const } : {})}
101
- >
102
- {props.children}
103
- </div>
104
- )
105
-
106
- const content = behaviour.backdrop ? (
107
- <>
108
- {}
109
- <div data-backdrop="true" aria-hidden="true" />
110
- {surface}
111
- </>
112
- ) : (
113
- surface
114
- )
115
-
116
- if (!behaviour.portal) return content
117
-
118
- return createPortal(content, document.body)
119
- }
@@ -1,184 +0,0 @@
1
- 'use client'
2
-
3
- import { useCallback, useContext, useEffect, useMemo, useSyncExternalStore, type ReactNode } from 'react'
4
-
5
- import { en } from './i18n/catalogs/en.ts'
6
- import { createTranslator } from './i18n/translator.ts'
7
- import { EDGE_SCROLL_DEFAULTS } from './drag.ts'
8
-
9
- import { createCollector, INERT_COLLECTOR, type CollectorOptions } from './collector.ts'
10
- import { Clock, Ticker } from './door.ts'
11
- import { UiContext, UiProvider, type Appearance, type UiConfig } from './wrappers.tsx'
12
-
13
- export interface ThreadUiProps {
14
- readonly children: ReactNode
15
-
16
- readonly palette?: { readonly accent: string; readonly surface: string }
17
-
18
- readonly mode?: 'light' | 'dark'
19
- readonly locale?: string
20
-
21
- readonly wording?: Readonly<Record<string, Readonly<Record<string, string>>>>
22
-
23
- readonly actor?: { readonly id: string | null; readonly roles: readonly string[]; readonly entitlements: readonly string[] }
24
-
25
- readonly asking?: readonly { readonly id: string; readonly selector: string; readonly when?: string }[]
26
-
27
- readonly permits?: (action: string) => boolean
28
-
29
- readonly platform?: string
30
-
31
- readonly telemetry?: {
32
-
33
- readonly endpoint?: string
34
- readonly allowed: () => boolean
35
-
36
- readonly sample?: number
37
-
38
- readonly beacon?: CollectorOptions['beacon']
39
- readonly fetch?: CollectorOptions['fetch']
40
- }
41
-
42
- readonly dispatch?: (name: string, input?: Readonly<Record<string, unknown>>) => Promise<unknown>
43
-
44
- readonly navigateTo?: (screen: string) => void
45
-
46
- readonly config?: UiConfig
47
- }
48
-
49
- function useMedia(query: string, fallback: boolean): boolean {
50
-
51
- const subscribe = useCallback(
52
- (onChange: () => void) => {
53
- if (typeof globalThis.matchMedia !== 'function') return () => {}
54
- const list = globalThis.matchMedia(query)
55
- list.addEventListener('change', onChange)
56
- return () => list.removeEventListener('change', onChange)
57
- },
58
- [query],
59
- )
60
-
61
- const read = useCallback(
62
- () => (typeof globalThis.matchMedia === 'function' ? globalThis.matchMedia(query).matches : fallback),
63
- [query, fallback],
64
- )
65
-
66
- return useSyncExternalStore(subscribe, read, () => fallback)
67
- }
68
-
69
- function readPalette(): { accent: string; surface: string } {
70
- if (typeof globalThis.getComputedStyle !== 'function' || typeof globalThis.document !== 'object') {
71
- return { accent: '', surface: '' }
72
- }
73
- const style = globalThis.getComputedStyle(globalThis.document.documentElement)
74
- const read = (name: string): string => style.getPropertyValue(name).trim()
75
- return {
76
- accent: read('--thread-palette-accent'),
77
- surface: read('--thread-palette-surface'),
78
- }
79
- }
80
-
81
- function readEdgeScroll(): { band: number; speed: number } {
82
- if (typeof globalThis.getComputedStyle !== 'function' || typeof globalThis.document !== 'object') return { ...EDGE_SCROLL_DEFAULTS }
83
- const style = globalThis.getComputedStyle(globalThis.document.documentElement)
84
- const read = (name: string, fallback: number): number => {
85
- const value = Number.parseFloat(style.getPropertyValue(name))
86
- return Number.isFinite(value) ? value : fallback
87
- }
88
- return {
89
- band: read('--thread-edge-scroll-band', EDGE_SCROLL_DEFAULTS.band),
90
- speed: read('--thread-edge-scroll-speed', EDGE_SCROLL_DEFAULTS.speed),
91
- }
92
- }
93
-
94
- export function ThreadUi(props: ThreadUiProps): ReactNode {
95
-
96
- const existing = useContext(UiContext)
97
- const opinionated =
98
- props.mode !== undefined ||
99
- props.locale !== undefined ||
100
- props.palette !== undefined ||
101
- props.platform !== undefined ||
102
- props.telemetry !== undefined ||
103
- props.wording !== undefined ||
104
- props.config !== undefined
105
-
106
- const prefersDark = useMedia('(prefers-color-scheme: dark)', false)
107
- const reduceMotion = useMedia('(prefers-reduced-motion: reduce)', false)
108
-
109
- const appearance: Appearance = {
110
- mode: props.mode ?? (prefersDark ? 'dark' : 'light'),
111
- reduceMotion,
112
- }
113
-
114
- const language = props.locale ?? 'en'
115
- const translate = useMemo(() => {
116
- const held = props.wording?.[language]
117
- if (props.wording !== undefined && held === undefined) {
118
- throw new Error(
119
- `This app was handed wording and none of it is in "${language}". It has ` +
120
- `${Object.keys(props.wording).sort().join(', ')} — declare the language in ` +
121
- '`_threads/localizations.ts` and run `thread i18n`, or ask for one it is in.',
122
- )
123
- }
124
- return createTranslator(held ?? en, { locale: language })
125
- }, [language, props.wording])
126
-
127
- const clock = useMemo(() => new Clock(), [])
128
-
129
- const ticker = useMemo(() => new Ticker(), [])
130
-
131
- const telemetryOff = props.telemetry === undefined
132
-
133
- const collector = useMemo(() => {
134
- if (props.telemetry === undefined) return INERT_COLLECTOR
135
- return createCollector({
136
- endpoint: props.telemetry.endpoint ?? '/obs/client',
137
- allowed: props.telemetry.allowed,
138
- ...(props.telemetry.sample === undefined ? {} : { sample: props.telemetry.sample }),
139
- ...(props.telemetry.beacon === undefined ? {} : { beacon: props.telemetry.beacon }),
140
- ...(props.telemetry.fetch === undefined ? {} : { fetch: props.telemetry.fetch }),
141
- })
142
-
143
- }, [telemetryOff, props.telemetry?.endpoint, props.telemetry?.sample, props.telemetry?.allowed, props.telemetry?.beacon, props.telemetry?.fetch])
144
-
145
- useEffect(() => () => collector.stop(), [collector])
146
-
147
- const gate = props.permits
148
- const asked = props.asking
149
-
150
- const holding = useMemo(
151
- () =>
152
- gate === undefined
153
- ? []
154
- : (asked ?? []).filter((one) => one.when !== undefined && !gate(one.when)).map((one) => one.id),
155
- [gate, asked],
156
- )
157
-
158
- const derived: UiConfig = useMemo(
159
- () => ({
160
- palette: props.palette ?? readPalette(),
161
- appearance,
162
- clock,
163
- ticker,
164
-
165
- now: () => Date.now(),
166
- edgeScroll: readEdgeScroll(),
167
-
168
- ...(props.actor === undefined ? {} : { actor: props.actor }),
169
- ...(props.asking === undefined ? {} : { asking: props.asking }),
170
- ...(holding.length === 0 ? {} : { holding }),
171
- ...(props.platform === undefined ? {} : { platform: props.platform }),
172
- ...(props.dispatch === undefined ? {} : { dispatch: props.dispatch }),
173
- ...(props.navigateTo === undefined ? {} : { navigateTo: props.navigateTo }),
174
- collector,
175
- translate: (key, params) => translate(key as never, params as never),
176
- }),
177
-
178
- [props.actor, props.asking, props.palette, props.platform, props.dispatch, props.navigateTo, holding, appearance.mode, appearance.reduceMotion, clock, ticker, collector, translate],
179
- )
180
-
181
- if (existing !== null && !opinionated) return props.children
182
-
183
- return <UiProvider config={props.config ?? derived}>{props.children}</UiProvider>
184
- }
@@ -1,133 +0,0 @@
1
- 'use client'
2
-
3
- import { useRef } from 'react'
4
-
5
- const RENDER_LIMIT = 40
6
-
7
- const WINDOW_MS = 1000
8
-
9
- const COMPARE_DEPTH = 3
10
-
11
- function armed(): boolean {
12
-
13
- return process.env.NODE_ENV !== 'production'
14
- }
15
-
16
- export interface RenderLoop {
17
-
18
- readonly component: string
19
- readonly renders: number
20
- readonly withinMs: number
21
-
22
- readonly unstable: readonly string[]
23
-
24
- readonly differing: readonly string[]
25
- }
26
-
27
- export class RenderLoopError extends Error {
28
- readonly loop: RenderLoop
29
-
30
- constructor(loop: RenderLoop) {
31
- super(describe(loop))
32
- this.name = 'RenderLoopError'
33
- this.loop = loop
34
- }
35
- }
36
-
37
- function describe(loop: RenderLoop): string {
38
- const head = `${loop.component} rendered ${loop.renders} times in ${loop.withinMs}ms, which is a render loop.`
39
-
40
- if (loop.unstable.length > 0) {
41
- const props = loop.unstable.join(', ')
42
- return (
43
- `${head} ${props} arrived with a new identity every render while its value stayed equal, so ` +
44
- `every render caused the next one. Return the same object for the same answer — that is the ` +
45
- `read layer's job (ADR-180 rule 10), not the component's — or compute the value inside render ` +
46
- `instead of syncing it into state from an effect (rule 11).`
47
- )
48
- }
49
-
50
- if (loop.differing.length > 0) {
51
- return (
52
- `${head} ${loop.differing.join(', ')} changed VALUE on every render, so this is not an identity ` +
53
- `problem: something is writing during render. Move the write to an event or an action.`
54
- )
55
- }
56
-
57
- return (
58
- `${head} No prop changed at all, so the re-render comes from state or context inside the ` +
59
- `component — look for a setter called from an effect body (ADR-180 rule 11).`
60
- )
61
- }
62
-
63
- function equalValue(a: unknown, b: unknown, depth = 0): boolean {
64
- if (Object.is(a, b)) return true
65
- if (depth >= COMPARE_DEPTH) return false
66
- if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false
67
-
68
- if (Array.isArray(a) || Array.isArray(b)) {
69
- if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false
70
- return a.every((item, index) => equalValue(item, b[index], depth + 1))
71
- }
72
-
73
- const left = a as Record<string, unknown>
74
- const right = b as Record<string, unknown>
75
- const keys = Object.keys(left)
76
- if (keys.length !== Object.keys(right).length) return false
77
- return keys.every((key) => key in right && equalValue(left[key], right[key], depth + 1))
78
- }
79
-
80
- function compare(
81
- previous: Record<string, unknown>,
82
- next: Record<string, unknown>,
83
- ): { unstable: string[]; differing: string[] } {
84
- const unstable: string[] = []
85
- const differing: string[] = []
86
-
87
- for (const key of [...new Set([...Object.keys(previous), ...Object.keys(next)])].sort()) {
88
- if (Object.is(previous[key], next[key])) continue
89
- if (equalValue(previous[key], next[key])) unstable.push(key)
90
- else differing.push(key)
91
- }
92
-
93
- return { unstable, differing }
94
- }
95
-
96
- export function useRenderLoopDetector(component: string, props: Record<string, unknown>): void {
97
- const renders = useRef(0)
98
- const openedAt = useRef(0)
99
- const previous = useRef<Record<string, unknown> | undefined>(undefined)
100
- const tripped = useRef<RenderLoop | undefined>(undefined)
101
-
102
- if (armed()) {
103
-
104
- if (tripped.current !== undefined) throw new RenderLoopError(tripped.current)
105
-
106
- const now = Date.now()
107
- const elapsed = now - openedAt.current
108
-
109
- if (openedAt.current === 0 || elapsed > WINDOW_MS) {
110
- openedAt.current = now
111
- renders.current = 0
112
- }
113
-
114
- renders.current += 1
115
-
116
- if (renders.current > RENDER_LIMIT) {
117
- const loop: RenderLoop = {
118
- component,
119
- renders: renders.current,
120
-
121
- withinMs: now - openedAt.current,
122
- ...compare(previous.current ?? {}, props),
123
- }
124
-
125
- tripped.current = loop
126
- renders.current = 0
127
- openedAt.current = now
128
- throw new RenderLoopError(loop)
129
- }
130
- }
131
-
132
- previous.current = props
133
- }
@@ -1,161 +0,0 @@
1
- 'use client'
2
-
3
- import { useCallback, useEffect, useRef, type RefObject } from 'react'
4
-
5
- export const ITEM_ATTR = 'data-roving-item'
6
-
7
- export interface RovingOptions {
8
- readonly orientation: 'vertical' | 'horizontal'
9
-
10
- readonly wrap?: boolean
11
- readonly active?: boolean
12
- }
13
-
14
- interface Item {
15
- readonly element: HTMLElement
16
- readonly disabled: boolean
17
- readonly text: string
18
- }
19
-
20
- function items(root: HTMLElement | null): readonly Item[] {
21
- if (root === null) return []
22
- return [...root.querySelectorAll<HTMLElement>(`[${ITEM_ATTR}]`)].map((element) => ({
23
- element,
24
-
25
- disabled: element.hasAttribute('disabled') || element.getAttribute('aria-disabled') === 'true',
26
- text: (element.textContent ?? '').trim().toLowerCase(),
27
- }))
28
- }
29
-
30
- function step(list: readonly Item[], from: number, direction: 1 | -1, wrap: boolean): number {
31
- const count = list.length
32
- if (count === 0) return -1
33
- let index = from
34
- for (let moved = 0; moved < count; moved += 1) {
35
- const next = index + direction
36
- if (next < 0 || next >= count) {
37
- if (!wrap) return from
38
- index = direction === 1 ? 0 : count - 1
39
- } else {
40
- index = next
41
- }
42
- if (!(list[index] as Item).disabled) return index
43
- }
44
- return from
45
- }
46
-
47
- function firstEnabled(list: readonly Item[], from = 0): number {
48
- for (let i = from; i < list.length; i += 1) if (!(list[i] as Item).disabled) return i
49
- return -1
50
- }
51
-
52
- function lastEnabled(list: readonly Item[]): number {
53
- for (let i = list.length - 1; i >= 0; i -= 1) if (!(list[i] as Item).disabled) return i
54
- return -1
55
- }
56
-
57
- function isRtl(element: HTMLElement): boolean {
58
- const declared = element.closest('[dir]')?.getAttribute('dir')?.toLowerCase()
59
- if (declared === 'rtl') return true
60
- if (declared === 'ltr') return false
61
- return getComputedStyle(element).direction === 'rtl'
62
- }
63
-
64
- const TYPEAHEAD_MS = 1000
65
-
66
- export function typeaheadMatch(list: readonly Item[], buffer: string, current: number): number {
67
- const enabled = list.map((item, index) => ({ item, index })).filter(({ item }) => !item.disabled)
68
- if (enabled.length === 0 || buffer.length === 0) return -1
69
-
70
- const repeated = buffer.length > 1 && [...buffer].every((c) => c === buffer[0])
71
- const prefix = repeated ? (buffer[0] as string) : buffer
72
-
73
- const startAt = enabled.findIndex(({ index }) => index === current)
74
- for (let i = 0; i < enabled.length; i += 1) {
75
- const candidate = enabled[(startAt + 1 + i) % enabled.length] as { item: Item; index: number }
76
- if (candidate.item.text.startsWith(prefix)) return candidate.index
77
- }
78
- return -1
79
- }
80
-
81
- export function useRovingFocus(ref: RefObject<HTMLElement | null>, options: RovingOptions): void {
82
- const { orientation, wrap = false, active = true } = options
83
- const indexRef = useRef(0)
84
- const bufferRef = useRef('')
85
- const timerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
86
-
87
- const applyTabIndex = useCallback(
88
- (list: readonly Item[], index: number): void => {
89
- list.forEach((item, i) => {
90
- item.element.setAttribute('tabindex', i === index ? '0' : '-1')
91
- })
92
- },
93
- [],
94
- )
95
-
96
- useEffect(() => {
97
- if (!active) return
98
- const root = ref.current
99
- if (root === null) return
100
-
101
- const list = items(root)
102
- const start = firstEnabled(list)
103
- indexRef.current = start === -1 ? 0 : start
104
- applyTabIndex(list, indexRef.current)
105
-
106
- const move = (to: number): void => {
107
- const current = items(root)
108
- if (to < 0 || to >= current.length) return
109
- indexRef.current = to
110
- applyTabIndex(current, to)
111
- ;(current[to] as Item).element.focus()
112
- }
113
-
114
- const onKeyDown = (event: KeyboardEvent): void => {
115
- const current = items(root)
116
- if (current.length === 0) return
117
-
118
- const rtl = isRtl(root)
119
- const forward = orientation === 'horizontal' ? (rtl ? 'ArrowLeft' : 'ArrowRight') : 'ArrowDown'
120
- const backward = orientation === 'horizontal' ? (rtl ? 'ArrowRight' : 'ArrowLeft') : 'ArrowUp'
121
-
122
- if (event.key === forward) {
123
- event.preventDefault()
124
- move(step(current, indexRef.current, 1, wrap))
125
- return
126
- }
127
- if (event.key === backward) {
128
- event.preventDefault()
129
- move(step(current, indexRef.current, -1, wrap))
130
- return
131
- }
132
- if (event.key === 'Home') {
133
- event.preventDefault()
134
- move(firstEnabled(current))
135
- return
136
- }
137
- if (event.key === 'End') {
138
- event.preventDefault()
139
- move(lastEnabled(current))
140
- return
141
- }
142
-
143
- if (event.key.length !== 1 || event.ctrlKey || event.metaKey || event.altKey) return
144
- event.preventDefault()
145
- bufferRef.current += event.key.toLowerCase()
146
- if (timerRef.current !== undefined) clearTimeout(timerRef.current)
147
- timerRef.current = setTimeout(() => {
148
- bufferRef.current = ''
149
- }, TYPEAHEAD_MS)
150
-
151
- const found = typeaheadMatch(current, bufferRef.current, indexRef.current)
152
- if (found !== -1) move(found)
153
- }
154
-
155
- root.addEventListener('keydown', onKeyDown)
156
- return () => {
157
- root.removeEventListener('keydown', onKeyDown)
158
- if (timerRef.current !== undefined) clearTimeout(timerRef.current)
159
- }
160
- }, [active, orientation, wrap, ref, applyTabIndex])
161
- }