@prestack/thread-ui 0.0.1 → 0.1.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.
- package/CHANGELOG.md +177 -0
- package/dist/ui/animations/anm_appear.ts +13 -0
- package/dist/ui/animations/anm_blur_in.ts +19 -0
- package/dist/ui/animations/anm_blur_out.ts +18 -0
- package/dist/ui/animations/anm_dismiss.ts +18 -0
- package/dist/ui/animations/anm_drop.ts +19 -0
- package/dist/ui/animations/anm_fall.ts +18 -0
- package/dist/ui/animations/anm_flash.ts +18 -0
- package/dist/ui/animations/anm_glow.ts +16 -0
- package/dist/ui/animations/anm_lift.ts +16 -0
- package/dist/ui/animations/anm_nudge.ts +16 -0
- package/dist/ui/animations/anm_pop_in.ts +23 -0
- package/dist/ui/animations/anm_pop_out.ts +22 -0
- package/dist/ui/animations/anm_press.ts +12 -0
- package/dist/ui/animations/anm_pulse.ts +13 -0
- package/dist/ui/animations/anm_rise.ts +19 -0
- package/dist/ui/animations/anm_shake.ts +13 -0
- package/dist/ui/animations/anm_slide_in_left.ts +19 -0
- package/dist/ui/animations/anm_slide_in_right.ts +19 -0
- package/dist/ui/animations/anm_slide_out_left.ts +18 -0
- package/dist/ui/animations/anm_slide_out_right.ts +18 -0
- package/dist/ui/animations/anm_spin.ts +12 -0
- package/dist/ui/animations/anm_swell.ts +16 -0
- package/dist/ui/animations/anm_wobble.ts +24 -0
- package/dist/ui/animations/anm_zoom_in.ts +19 -0
- package/dist/ui/animations/anm_zoom_out.ts +18 -0
- package/dist/ui/animations.js +2 -3
- package/dist/ui/animations.ts +316 -0
- package/dist/ui/attach.js +13 -10
- package/dist/ui/attach.ts +607 -0
- package/dist/ui/charts.tsx +74 -0
- package/dist/ui/checked.d.ts +2 -2
- package/dist/ui/checked.ts +32 -0
- package/dist/ui/collector.ts +140 -0
- package/dist/ui/compositions.d.ts +11 -2
- package/dist/ui/compositions.js +283 -59
- package/dist/ui/compositions.ts +633 -0
- package/dist/ui/consent.ts +55 -0
- package/dist/ui/content.tsx +39 -0
- package/dist/ui/contrast.ts +27 -0
- package/dist/ui/controls.tsx +30 -0
- package/dist/ui/disclosure.tsx +22 -0
- package/dist/ui/dismiss-layer.ts +87 -0
- package/dist/ui/door.ts +140 -0
- package/dist/ui/drag.ts +316 -0
- package/dist/ui/elements/accordion.js +2 -2
- package/dist/ui/elements/accordion.tsx +47 -0
- package/dist/ui/elements/accordion.works.ts +66 -0
- package/dist/ui/elements/aspect-ratio.js +1 -1
- package/dist/ui/elements/aspect-ratio.tsx +30 -0
- package/dist/ui/elements/aspect-ratio.works.ts +5 -0
- package/dist/ui/elements/avatar.js +1 -1
- package/dist/ui/elements/avatar.tsx +33 -0
- package/dist/ui/elements/avatar.works.ts +16 -0
- package/dist/ui/elements/badge.js +1 -1
- package/dist/ui/elements/badge.tsx +32 -0
- package/dist/ui/elements/badge.works.ts +9 -0
- package/dist/ui/elements/bars.js +2 -2
- package/dist/ui/elements/bars.tsx +49 -0
- package/dist/ui/elements/bars.works.ts +29 -0
- package/dist/ui/elements/box.js +3 -2
- package/dist/ui/elements/box.tsx +35 -0
- package/dist/ui/elements/breadcrumb.js +2 -2
- package/dist/ui/elements/breadcrumb.tsx +37 -0
- package/dist/ui/elements/breadcrumb.works.ts +24 -0
- package/dist/ui/elements/button.js +1 -1
- package/dist/ui/elements/button.tsx +45 -0
- package/dist/ui/elements/button.works.tsx +52 -0
- package/dist/ui/elements/column.js +1 -1
- package/dist/ui/elements/column.tsx +28 -0
- package/dist/ui/elements/consent.js +2 -2
- package/dist/ui/elements/consent.tsx +70 -0
- package/dist/ui/elements/consent.works.tsx +132 -0
- package/dist/ui/elements/donut.js +2 -2
- package/dist/ui/elements/donut.tsx +44 -0
- package/dist/ui/elements/donut.works.ts +42 -0
- package/dist/ui/elements/each.js +1 -1
- package/dist/ui/elements/each.tsx +33 -0
- package/dist/ui/elements/each.works.tsx +82 -0
- package/dist/ui/elements/empty.js +1 -1
- package/dist/ui/elements/empty.tsx +32 -0
- package/dist/ui/elements/field.js +1 -1
- package/dist/ui/elements/field.tsx +44 -0
- package/dist/ui/elements/field.works.ts +65 -0
- package/dist/ui/elements/funnel.js +2 -2
- package/dist/ui/elements/funnel.tsx +49 -0
- package/dist/ui/elements/funnel.works.ts +37 -0
- package/dist/ui/elements/gauge.js +1 -1
- package/dist/ui/elements/gauge.tsx +39 -0
- package/dist/ui/elements/gauge.works.ts +42 -0
- package/dist/ui/elements/glyph.js +1 -1
- package/dist/ui/elements/glyph.tsx +29 -0
- package/dist/ui/elements/glyph.works.tsx +42 -0
- package/dist/ui/elements/grid.js +1 -1
- package/dist/ui/elements/grid.tsx +28 -0
- package/dist/ui/elements/heatmap.js +2 -2
- package/dist/ui/elements/heatmap.tsx +47 -0
- package/dist/ui/elements/heatmap.works.ts +24 -0
- package/dist/ui/elements/hit-box.js +1 -1
- package/dist/ui/elements/hit-box.tsx +46 -0
- package/dist/ui/elements/hit-box.works.tsx +576 -0
- package/dist/ui/elements/image.js +1 -1
- package/dist/ui/elements/image.tsx +28 -0
- package/dist/ui/elements/line-chart.js +2 -2
- package/dist/ui/elements/line-chart.tsx +44 -0
- package/dist/ui/elements/line-chart.works.ts +41 -0
- package/dist/ui/elements/link.js +1 -1
- package/dist/ui/elements/link.tsx +37 -0
- package/dist/ui/elements/link.works.ts +25 -0
- package/dist/ui/elements/media.js +1 -1
- package/dist/ui/elements/media.tsx +56 -0
- package/dist/ui/elements/menu.js +2 -2
- package/dist/ui/elements/menu.tsx +50 -0
- package/dist/ui/elements/menu.works.tsx +153 -0
- package/dist/ui/elements/money.js +1 -1
- package/dist/ui/elements/money.tsx +28 -0
- package/dist/ui/elements/money.works.ts +9 -0
- package/dist/ui/elements/pagination.js +2 -2
- package/dist/ui/elements/pagination.tsx +38 -0
- package/dist/ui/elements/pagination.works.ts +20 -0
- package/dist/ui/elements/progress.js +1 -1
- package/dist/ui/elements/progress.tsx +33 -0
- package/dist/ui/elements/progress.works.ts +8 -0
- package/dist/ui/elements/radio-group.js +2 -2
- package/dist/ui/elements/radio-group.tsx +49 -0
- package/dist/ui/elements/radio-group.works.ts +33 -0
- package/dist/ui/elements/row.js +1 -1
- package/dist/ui/elements/row.tsx +28 -0
- package/dist/ui/elements/scatter.js +1 -1
- package/dist/ui/elements/scatter.tsx +41 -0
- package/dist/ui/elements/scatter.works.ts +38 -0
- package/dist/ui/elements/select.js +2 -2
- package/dist/ui/elements/select.tsx +42 -0
- package/dist/ui/elements/select.works.ts +5 -0
- package/dist/ui/elements/separator.js +1 -1
- package/dist/ui/elements/separator.tsx +30 -0
- package/dist/ui/elements/skeleton.js +1 -1
- package/dist/ui/elements/skeleton.tsx +30 -0
- package/dist/ui/elements/slider.js +1 -1
- package/dist/ui/elements/slider.tsx +33 -0
- package/dist/ui/elements/slider.works.ts +5 -0
- package/dist/ui/elements/sparkline.js +1 -1
- package/dist/ui/elements/sparkline.tsx +36 -0
- package/dist/ui/elements/sparkline.works.ts +32 -0
- package/dist/ui/elements/spinner.js +1 -1
- package/dist/ui/elements/spinner.tsx +32 -0
- package/dist/ui/elements/stack.js +1 -1
- package/dist/ui/elements/stack.tsx +28 -0
- package/dist/ui/elements/stat.js +1 -1
- package/dist/ui/elements/stat.tsx +35 -0
- package/dist/ui/elements/stat.works.ts +14 -0
- package/dist/ui/elements/surface.js +1 -1
- package/dist/ui/elements/surface.tsx +35 -0
- package/dist/ui/elements/surface.works.ts +9 -0
- package/dist/ui/elements/table.js +2 -2
- package/dist/ui/elements/table.tsx +61 -0
- package/dist/ui/elements/table.works.ts +30 -0
- package/dist/ui/elements/tabs.js +2 -2
- package/dist/ui/elements/tabs.tsx +47 -0
- package/dist/ui/elements/tabs.works.ts +84 -0
- package/dist/ui/elements/text.js +1 -1
- package/dist/ui/elements/text.tsx +32 -0
- package/dist/ui/elements/textarea.js +1 -1
- package/dist/ui/elements/textarea.tsx +37 -0
- package/dist/ui/elements/textarea.works.ts +5 -0
- package/dist/ui/elements/time.js +1 -1
- package/dist/ui/elements/time.tsx +34 -0
- package/dist/ui/elements/time.works.ts +25 -0
- package/dist/ui/elements/toggle.js +1 -1
- package/dist/ui/elements/toggle.tsx +33 -0
- package/dist/ui/elements/toggle.works.ts +5 -0
- package/dist/ui/elements/tooltip.js +1 -1
- package/dist/ui/elements/tooltip.tsx +39 -0
- package/dist/ui/elements/tooltip.works.ts +77 -0
- package/dist/ui/elements/value.js +1 -1
- package/dist/ui/elements/value.tsx +30 -0
- package/dist/ui/elements/value.works.ts +9 -0
- package/dist/ui/elements.ts +127 -0
- package/dist/ui/focus-scope.ts +121 -0
- package/dist/ui/fonts.ts +355 -0
- package/dist/ui/generators/dev.js +2 -1
- package/dist/ui/generators/dev.ts +80 -0
- package/dist/ui/generators/elements.d.ts +1 -11
- package/dist/ui/generators/elements.js +44 -14
- package/dist/ui/generators/elements.ts +794 -0
- package/dist/ui/generators/i18n.js +2 -3
- package/dist/ui/generators/i18n.ts +300 -0
- package/dist/ui/generators/icons.js +3 -12
- package/dist/ui/generators/icons.ts +214 -0
- package/dist/ui/generators/react.js +13 -5
- package/dist/ui/generators/react.ts +269 -0
- package/dist/ui/generators/routes.d.ts +1 -0
- package/dist/ui/generators/routes.js +99 -83
- package/dist/ui/generators/routes.ts +743 -0
- package/dist/ui/generators/show.js +4 -12
- package/dist/ui/generators/show.ts +260 -0
- package/dist/ui/generators/styles.js +3 -4
- package/dist/ui/generators/styles.ts +669 -0
- package/dist/ui/generators/variants.js +2 -3
- package/dist/ui/generators/variants.ts +204 -0
- package/dist/ui/i18n/catalogs/en.ts +191 -0
- package/dist/ui/i18n/format.ts +221 -0
- package/dist/ui/i18n/locales.ts +33 -0
- package/dist/ui/i18n/parse.ts +315 -0
- package/dist/ui/i18n/translator.ts +31 -0
- package/dist/ui/i18n/types.ts +96 -0
- package/dist/ui/icons.ts +331 -0
- package/dist/ui/islands/map.ts +58 -0
- package/dist/ui/localizations.ts +75 -0
- package/dist/ui/material.ts +236 -0
- package/dist/ui/materials/mat_bordered.ts +13 -0
- package/dist/ui/materials/mat_ceramic.ts +13 -0
- package/dist/ui/materials/mat_clay.ts +13 -0
- package/dist/ui/materials/mat_elevated.ts +24 -0
- package/dist/ui/materials/mat_flat.ts +34 -0
- package/dist/ui/materials/mat_glass.ts +20 -0
- package/dist/ui/materials/mat_lit.ts +13 -0
- package/dist/ui/materials/mat_moulded.ts +11 -0
- package/dist/ui/materials.js +2 -3
- package/dist/ui/materials.ts +205 -0
- package/dist/ui/modes.ts +44 -0
- package/dist/ui/navigation.tsx +32 -0
- package/dist/ui/positioning.ts +90 -0
- package/dist/ui/presented.tsx +119 -0
- package/dist/ui/provider.d.ts +2 -0
- package/dist/ui/provider.js +7 -1
- package/dist/ui/provider.tsx +182 -0
- package/dist/ui/render-loop.ts +133 -0
- package/dist/ui/roving-focus.ts +161 -0
- package/dist/ui/screens.js +22 -22
- package/dist/ui/screens.ts +141 -0
- package/dist/ui/scroll-lock.ts +28 -0
- package/dist/ui/series.ts +97 -0
- package/dist/ui/signals.ts +386 -0
- package/dist/ui/styleguide.ts +231 -0
- package/dist/ui/styleguides.ts +374 -0
- package/dist/ui/themes.ts +22 -0
- package/dist/ui/token.js +1 -1
- package/dist/ui/token.ts +78 -0
- package/dist/ui/tokens/shadow.ts +25 -0
- package/dist/ui/tokens.js +1 -2
- package/dist/ui/tokens.ts +234 -0
- package/dist/ui/validators.ts +232 -0
- package/dist/ui/variant.d.ts +1 -0
- package/dist/ui/variant.js +3 -0
- package/dist/ui/variant.ts +352 -0
- package/dist/ui/variants/var_avatar-round.ts +14 -0
- package/dist/ui/variants/var_bubble-me.ts +14 -0
- package/dist/ui/variants/var_bubble-them.ts +13 -0
- package/dist/ui/variants/var_button-border.ts +76 -0
- package/dist/ui/variants/var_button-cta.ts +76 -0
- package/dist/ui/variants/var_button-destructive-ghost.ts +72 -0
- package/dist/ui/variants/var_button-destructive.ts +76 -0
- package/dist/ui/variants/var_button-fill.ts +76 -0
- package/dist/ui/variants/var_button-ghost.ts +72 -0
- package/dist/ui/variants/var_button-glass.ts +55 -0
- package/dist/ui/variants/var_button-normal.ts +76 -0
- package/dist/ui/variants/var_button-quiet.ts +74 -0
- package/dist/ui/variants/var_card.ts +17 -0
- package/dist/ui/variants/var_chip.ts +13 -0
- package/dist/ui/variants/var_choice.ts +15 -0
- package/dist/ui/variants/var_framed.ts +10 -0
- package/dist/ui/variants/var_glyph-plain.ts +11 -0
- package/dist/ui/variants/var_input.ts +43 -0
- package/dist/ui/variants/var_link-inline.ts +29 -0
- package/dist/ui/variants/var_list-row.ts +34 -0
- package/dist/ui/variants/var_rule.ts +10 -0
- package/dist/ui/variants/var_skeleton-block.ts +12 -0
- package/dist/ui/variants/var_spinner-turning.ts +12 -0
- package/dist/ui/variants/var_stat-tile.ts +13 -0
- package/dist/ui/variants/var_tab-bar.ts +15 -0
- package/dist/ui/variants/var_tap-target.ts +25 -0
- package/dist/ui/variants.ts +64 -0
- package/dist/ui/wrappers.d.ts +4 -1
- package/dist/ui/wrappers.js +60 -20
- package/dist/ui/wrappers.tsx +426 -0
- package/package.json +8 -2
- package/dist/ui/makers/message.d.ts +0 -1
- package/dist/ui/makers/message.js +0 -230
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import type { MapIsland, Screen } from './islands/map.ts'
|
|
4
|
+
|
|
5
|
+
import { Link } from './elements/link.tsx'
|
|
6
|
+
|
|
7
|
+
export type ScreenName = Screen['id']
|
|
8
|
+
import { element, useSurface } from './wrappers.tsx'
|
|
9
|
+
|
|
10
|
+
export function trailTo(map: MapIsland, to: ScreenName): readonly ScreenName[] {
|
|
11
|
+
const entries = map.screens.filter((s) => s.entry).map((s) => s.id)
|
|
12
|
+
if (entries.includes(to)) return [to]
|
|
13
|
+
|
|
14
|
+
const seen = new Set<ScreenName>(entries)
|
|
15
|
+
let frontier: readonly (readonly ScreenName[])[] = entries.map((id) => [id])
|
|
16
|
+
|
|
17
|
+
while (frontier.length > 0) {
|
|
18
|
+
const next: (readonly ScreenName[])[] = []
|
|
19
|
+
for (const path of frontier) {
|
|
20
|
+
const here = path[path.length - 1] as ScreenName
|
|
21
|
+
for (const link of map.links.filter((l) => l.from === here)) {
|
|
22
|
+
if (link.to === to) return [...path, link.to]
|
|
23
|
+
if (seen.has(link.to)) continue
|
|
24
|
+
seen.add(link.to)
|
|
25
|
+
next.push([...path, link.to])
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
frontier = next
|
|
29
|
+
}
|
|
30
|
+
return []
|
|
31
|
+
}
|
|
32
|
+
|
|
@@ -0,0 +1,90 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
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
|
+
}
|
package/dist/ui/provider.d.ts
CHANGED
|
@@ -17,7 +17,9 @@ export interface ThreadUiProps {
|
|
|
17
17
|
readonly asking?: readonly {
|
|
18
18
|
readonly id: string;
|
|
19
19
|
readonly selector: string;
|
|
20
|
+
readonly when?: string;
|
|
20
21
|
}[];
|
|
22
|
+
readonly permits?: (action: string) => boolean;
|
|
21
23
|
readonly platform?: string;
|
|
22
24
|
readonly telemetry?: {
|
|
23
25
|
readonly endpoint?: string;
|
package/dist/ui/provider.js
CHANGED
|
@@ -83,6 +83,11 @@ export function ThreadUi(props) {
|
|
|
83
83
|
});
|
|
84
84
|
}, [telemetryOff, props.telemetry?.endpoint, props.telemetry?.sample, props.telemetry?.allowed]);
|
|
85
85
|
useEffect(() => () => collector.stop(), [collector]);
|
|
86
|
+
const gate = props.permits;
|
|
87
|
+
const asked = props.asking;
|
|
88
|
+
const holding = useMemo(() => gate === undefined
|
|
89
|
+
? []
|
|
90
|
+
: (asked ?? []).filter((one) => one.when !== undefined && !gate(one.when)).map((one) => one.id), [gate, asked]);
|
|
86
91
|
const derived = useMemo(() => ({
|
|
87
92
|
palette: props.palette ?? readPalette(),
|
|
88
93
|
appearance,
|
|
@@ -92,12 +97,13 @@ export function ThreadUi(props) {
|
|
|
92
97
|
edgeScroll: readEdgeScroll(),
|
|
93
98
|
...(props.actor === undefined ? {} : { actor: props.actor }),
|
|
94
99
|
...(props.asking === undefined ? {} : { asking: props.asking }),
|
|
100
|
+
...(holding.length === 0 ? {} : { holding }),
|
|
95
101
|
...(props.platform === undefined ? {} : { platform: props.platform }),
|
|
96
102
|
...(props.dispatch === undefined ? {} : { dispatch: props.dispatch }),
|
|
97
103
|
...(props.navigateTo === undefined ? {} : { navigateTo: props.navigateTo }),
|
|
98
104
|
collector,
|
|
99
105
|
translate: (key, params) => translate(key, params),
|
|
100
|
-
}), [props.actor, props.asking, props.palette, props.platform, props.dispatch, props.navigateTo, appearance.mode, appearance.reduceMotion, clock, ticker, collector, translate]);
|
|
106
|
+
}), [props.actor, props.asking, props.palette, props.platform, props.dispatch, props.navigateTo, holding, appearance.mode, appearance.reduceMotion, clock, ticker, collector, translate]);
|
|
101
107
|
if (existing !== null && !opinionated)
|
|
102
108
|
return props.children;
|
|
103
109
|
return _jsx(UiProvider, { config: props.config ?? derived, children: props.children });
|
|
@@ -0,0 +1,182 @@
|
|
|
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 } 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
|
+
|
|
39
|
+
readonly dispatch?: (name: string, input?: Readonly<Record<string, unknown>>) => Promise<unknown>
|
|
40
|
+
|
|
41
|
+
readonly navigateTo?: (screen: string) => void
|
|
42
|
+
|
|
43
|
+
readonly config?: UiConfig
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function useMedia(query: string, fallback: boolean): boolean {
|
|
47
|
+
|
|
48
|
+
const subscribe = useCallback(
|
|
49
|
+
(onChange: () => void) => {
|
|
50
|
+
if (typeof globalThis.matchMedia !== 'function') return () => {}
|
|
51
|
+
const list = globalThis.matchMedia(query)
|
|
52
|
+
list.addEventListener('change', onChange)
|
|
53
|
+
return () => list.removeEventListener('change', onChange)
|
|
54
|
+
},
|
|
55
|
+
[query],
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
const read = useCallback(
|
|
59
|
+
() => (typeof globalThis.matchMedia === 'function' ? globalThis.matchMedia(query).matches : fallback),
|
|
60
|
+
[query, fallback],
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
return useSyncExternalStore(subscribe, read, () => fallback)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function readPalette(): { accent: string; surface: string } {
|
|
67
|
+
if (typeof globalThis.getComputedStyle !== 'function' || typeof globalThis.document !== 'object') {
|
|
68
|
+
return { accent: '', surface: '' }
|
|
69
|
+
}
|
|
70
|
+
const style = globalThis.getComputedStyle(globalThis.document.documentElement)
|
|
71
|
+
const read = (name: string): string => style.getPropertyValue(name).trim()
|
|
72
|
+
return {
|
|
73
|
+
accent: read('--thread-palette-accent'),
|
|
74
|
+
surface: read('--thread-palette-surface'),
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function readEdgeScroll(): { band: number; speed: number } {
|
|
79
|
+
if (typeof globalThis.getComputedStyle !== 'function' || typeof globalThis.document !== 'object') return { ...EDGE_SCROLL_DEFAULTS }
|
|
80
|
+
const style = globalThis.getComputedStyle(globalThis.document.documentElement)
|
|
81
|
+
const read = (name: string, fallback: number): number => {
|
|
82
|
+
const value = Number.parseFloat(style.getPropertyValue(name))
|
|
83
|
+
return Number.isFinite(value) ? value : fallback
|
|
84
|
+
}
|
|
85
|
+
return {
|
|
86
|
+
band: read('--thread-edge-scroll-band', EDGE_SCROLL_DEFAULTS.band),
|
|
87
|
+
speed: read('--thread-edge-scroll-speed', EDGE_SCROLL_DEFAULTS.speed),
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function ThreadUi(props: ThreadUiProps): ReactNode {
|
|
92
|
+
|
|
93
|
+
const existing = useContext(UiContext)
|
|
94
|
+
const opinionated =
|
|
95
|
+
props.mode !== undefined ||
|
|
96
|
+
props.locale !== undefined ||
|
|
97
|
+
props.palette !== undefined ||
|
|
98
|
+
props.platform !== undefined ||
|
|
99
|
+
props.telemetry !== undefined ||
|
|
100
|
+
props.wording !== undefined ||
|
|
101
|
+
props.config !== undefined
|
|
102
|
+
|
|
103
|
+
const prefersDark = useMedia('(prefers-color-scheme: dark)', false)
|
|
104
|
+
const reduceMotion = useMedia('(prefers-reduced-motion: reduce)', false)
|
|
105
|
+
|
|
106
|
+
const appearance: Appearance = {
|
|
107
|
+
mode: props.mode ?? (prefersDark ? 'dark' : 'light'),
|
|
108
|
+
reduceMotion,
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const language = props.locale ?? 'en'
|
|
112
|
+
const translate = useMemo(() => {
|
|
113
|
+
const held = props.wording?.[language]
|
|
114
|
+
if (props.wording !== undefined && held === undefined) {
|
|
115
|
+
throw new Error(
|
|
116
|
+
`This app was handed wording and none of it is in "${language}". It has ` +
|
|
117
|
+
`${Object.keys(props.wording).sort().join(', ')} — declare the language in ` +
|
|
118
|
+
'`_threads/localizations.ts` and run `thread i18n`, or ask for one it is in.',
|
|
119
|
+
)
|
|
120
|
+
}
|
|
121
|
+
return createTranslator(held ?? en, { locale: language })
|
|
122
|
+
}, [language, props.wording])
|
|
123
|
+
|
|
124
|
+
const clock = useMemo(() => new Clock(), [])
|
|
125
|
+
|
|
126
|
+
const ticker = useMemo(() => new Ticker(), [])
|
|
127
|
+
|
|
128
|
+
const telemetryOff = props.telemetry === undefined
|
|
129
|
+
|
|
130
|
+
const collector = useMemo(() => {
|
|
131
|
+
if (props.telemetry === undefined) return INERT_COLLECTOR
|
|
132
|
+
return createCollector({
|
|
133
|
+
endpoint: props.telemetry.endpoint ?? '/obs/client',
|
|
134
|
+
allowed: props.telemetry.allowed,
|
|
135
|
+
...(props.telemetry.sample === undefined ? {} : { sample: props.telemetry.sample }),
|
|
136
|
+
...(typeof navigator === 'object' && typeof navigator.sendBeacon === 'function'
|
|
137
|
+
? { beacon: (url: string, body: string) => navigator.sendBeacon(url, new Blob([body], { type: 'application/json' })) }
|
|
138
|
+
: {}),
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
}, [telemetryOff, props.telemetry?.endpoint, props.telemetry?.sample, props.telemetry?.allowed])
|
|
142
|
+
|
|
143
|
+
useEffect(() => () => collector.stop(), [collector])
|
|
144
|
+
|
|
145
|
+
const gate = props.permits
|
|
146
|
+
const asked = props.asking
|
|
147
|
+
|
|
148
|
+
const holding = useMemo(
|
|
149
|
+
() =>
|
|
150
|
+
gate === undefined
|
|
151
|
+
? []
|
|
152
|
+
: (asked ?? []).filter((one) => one.when !== undefined && !gate(one.when)).map((one) => one.id),
|
|
153
|
+
[gate, asked],
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
const derived: UiConfig = useMemo(
|
|
157
|
+
() => ({
|
|
158
|
+
palette: props.palette ?? readPalette(),
|
|
159
|
+
appearance,
|
|
160
|
+
clock,
|
|
161
|
+
ticker,
|
|
162
|
+
|
|
163
|
+
now: () => Date.now(),
|
|
164
|
+
edgeScroll: readEdgeScroll(),
|
|
165
|
+
|
|
166
|
+
...(props.actor === undefined ? {} : { actor: props.actor }),
|
|
167
|
+
...(props.asking === undefined ? {} : { asking: props.asking }),
|
|
168
|
+
...(holding.length === 0 ? {} : { holding }),
|
|
169
|
+
...(props.platform === undefined ? {} : { platform: props.platform }),
|
|
170
|
+
...(props.dispatch === undefined ? {} : { dispatch: props.dispatch }),
|
|
171
|
+
...(props.navigateTo === undefined ? {} : { navigateTo: props.navigateTo }),
|
|
172
|
+
collector,
|
|
173
|
+
translate: (key, params) => translate(key as never, params as never),
|
|
174
|
+
}),
|
|
175
|
+
|
|
176
|
+
[props.actor, props.asking, props.palette, props.platform, props.dispatch, props.navigateTo, holding, appearance.mode, appearance.reduceMotion, clock, ticker, collector, translate],
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
if (existing !== null && !opinionated) return props.children
|
|
180
|
+
|
|
181
|
+
return <UiProvider config={props.config ?? derived}>{props.children}</UiProvider>
|
|
182
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
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
|
+
}
|