@startsimpli/ui 0.4.93 → 0.4.94

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.
@@ -0,0 +1,171 @@
1
+ 'use client'
2
+
3
+ import * as React from 'react'
4
+ import { cn } from '../../lib/utils'
5
+ import { Caveat } from './Caveat'
6
+
7
+ interface FootnoteRegistry {
8
+ /** Distinct notes in the order they were first claimed. */
9
+ notes: string[]
10
+ register: (note: string) => void
11
+ unregister: (note: string) => void
12
+ noteId: (index: number) => string
13
+ }
14
+
15
+ const FootnoteContext = React.createContext<FootnoteRegistry | null>(null)
16
+
17
+ export interface FootnoteScopeProps {
18
+ children: React.ReactNode
19
+ /** Accessible name for the collected list. */
20
+ label?: string
21
+ className?: string
22
+ listClassName?: string
23
+ }
24
+
25
+ /**
26
+ * Collects the notes its children claim and prints each distinct one ONCE,
27
+ * underneath them.
28
+ *
29
+ * A caveat that belongs to seven tiles used to be authored inside the tile,
30
+ * which printed it seven times. Authored as a {@link Footnote} inside a scope,
31
+ * the same sentence is said once per section and each tile carries only a
32
+ * marker pointing at it.
33
+ */
34
+ export function FootnoteScope({
35
+ children,
36
+ label = 'Notes',
37
+ className,
38
+ listClassName,
39
+ }: FootnoteScopeProps) {
40
+ const scopeId = React.useId().replace(/:/g, '')
41
+ const counts = React.useRef<Map<string, number>>(new Map())
42
+ const [notes, setNotes] = React.useState<string[]>([])
43
+
44
+ const register = React.useCallback((note: string) => {
45
+ const next = (counts.current.get(note) ?? 0) + 1
46
+ counts.current.set(note, next)
47
+ if (next === 1) {
48
+ setNotes((prev) => (prev.includes(note) ? prev : [...prev, note]))
49
+ }
50
+ }, [])
51
+
52
+ const unregister = React.useCallback((note: string) => {
53
+ const next = (counts.current.get(note) ?? 1) - 1
54
+ if (next > 0) {
55
+ counts.current.set(note, next)
56
+ return
57
+ }
58
+ counts.current.delete(note)
59
+ setNotes((prev) => prev.filter((existing) => existing !== note))
60
+ }, [])
61
+
62
+ const noteId = React.useCallback(
63
+ (index: number) => `footnote-${scopeId}-${index}`,
64
+ [scopeId],
65
+ )
66
+
67
+ const registry = React.useMemo<FootnoteRegistry>(
68
+ () => ({ notes, register, unregister, noteId }),
69
+ [notes, register, unregister, noteId],
70
+ )
71
+
72
+ return (
73
+ <FootnoteContext.Provider value={registry}>
74
+ <div className={cn('space-y-4', className)}>
75
+ {children}
76
+ {notes.length > 0 && (
77
+ <ol
78
+ aria-label={label}
79
+ className={cn(
80
+ 'space-y-1 border-t border-border/60 pt-3 text-start',
81
+ listClassName,
82
+ )}
83
+ >
84
+ {notes.map((note, index) => (
85
+ <li
86
+ key={note}
87
+ id={noteId(index + 1)}
88
+ tabIndex={-1}
89
+ className="text-xs leading-relaxed text-muted-foreground"
90
+ >
91
+ <span aria-hidden="true" className="me-1 font-medium tabular-nums">
92
+ {index + 1}.
93
+ </span>
94
+ {note}
95
+ </li>
96
+ ))}
97
+ </ol>
98
+ )}
99
+ </div>
100
+ </FootnoteContext.Provider>
101
+ )
102
+ }
103
+
104
+ export interface FootnoteProps {
105
+ /** The sentence that should be said once per scope, however many items carry it. */
106
+ note: string
107
+ /** What the note qualifies — used when there is no scope to collect it. */
108
+ subject?: string
109
+ className?: string
110
+ }
111
+
112
+ /**
113
+ * A marker standing in for a note that belongs to the whole section.
114
+ *
115
+ * Inside a {@link FootnoteScope} it renders as a numbered reference to the one
116
+ * copy of the sentence printed under the section. With no scope above it, the
117
+ * note is not dropped — it falls back to a {@link Caveat} disclosure, so the
118
+ * information survives wherever the component is used.
119
+ */
120
+ export function Footnote({ note, subject, className }: FootnoteProps) {
121
+ const scope = React.useContext(FootnoteContext)
122
+ const register = scope?.register
123
+ const unregister = scope?.unregister
124
+
125
+ React.useEffect(() => {
126
+ if (!register || !unregister) return
127
+ register(note)
128
+ return () => unregister(note)
129
+ }, [register, unregister, note])
130
+
131
+ if (!scope) {
132
+ return (
133
+ <Caveat label="Note" subject={subject} className={className}>
134
+ {note}
135
+ </Caveat>
136
+ )
137
+ }
138
+
139
+ const index = scope.notes.indexOf(note) + 1
140
+ const markerClass = cn(
141
+ 'ms-0.5 inline-block align-super text-[10px] font-semibold leading-none tabular-nums text-muted-foreground',
142
+ className,
143
+ )
144
+
145
+ // Until the effect has registered the note there is no number to point at.
146
+ // A stable placeholder keeps the marker's footprint identical either way.
147
+ if (index === 0) {
148
+ return (
149
+ <span className={markerClass} data-footnote="pending">
150
+ <span aria-hidden="true">·</span>
151
+ </span>
152
+ )
153
+ }
154
+
155
+ const targetId = scope.noteId(index)
156
+
157
+ return (
158
+ <a
159
+ href={`#${targetId}`}
160
+ aria-describedby={targetId}
161
+ data-footnote={index}
162
+ className={cn(
163
+ markerClass,
164
+ 'rounded underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
165
+ )}
166
+ >
167
+ <span aria-hidden="true">{index}</span>
168
+ <span className="sr-only">{`Note ${index}`}</span>
169
+ </a>
170
+ )
171
+ }