@fabrika/ui 0.0.0-stage → 0.4.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/src/index.ts ADDED
@@ -0,0 +1,68 @@
1
+ export { AppShell, type AppShellProps, type NavItem } from './AppShell'
2
+ export { Button, ButtonLink, type ButtonLinkProps, type ButtonProps, type ButtonSize, type ButtonVariant } from './Button'
3
+ export { Card, type CardProps, Section, type SectionProps } from './Card'
4
+ export { Columns, type ColumnsProps, SidePanel, type SidePanelProps } from './Columns'
5
+ export {
6
+ type Column,
7
+ DataTable,
8
+ type DataTableProps,
9
+ type LoadMore,
10
+ type Selection,
11
+ type TableSelection,
12
+ type TableStatus,
13
+ useSelection,
14
+ } from './DataTable'
15
+ export { ConfirmDialog, type ConfirmDialogProps, Dialog, type DialogProps } from './Dialog'
16
+ export { Field, type FieldControl, type FieldProps, useFieldControl } from './Field'
17
+ export { FieldList, type FieldListItem, type FieldListProps } from './FieldList'
18
+ export { FilterBar, type FilterBarProps } from './FilterBar'
19
+ export { Form, FormActions, type FormActionsProps, type FormErrorLike, type FormProps } from './Form'
20
+ export {
21
+ Checkbox,
22
+ type CheckboxProps,
23
+ DateInput,
24
+ type DateInputProps,
25
+ DateRange,
26
+ type DateRangeProps,
27
+ type DateRangeValue,
28
+ Input,
29
+ type InputProps,
30
+ RadioGroup,
31
+ type RadioGroupProps,
32
+ Select,
33
+ type SelectOption,
34
+ type SelectProps,
35
+ Textarea,
36
+ type TextareaProps,
37
+ } from './inputs'
38
+ export { type Gap, Inline, type InlineProps, Stack, type StackProps } from './layout'
39
+ export { PageHeader, type PageHeaderProps } from './PageHeader'
40
+ export {
41
+ type CreatedRouter,
42
+ createRouter,
43
+ type LinkProps,
44
+ type NavigateOptions,
45
+ type ParamsArgs,
46
+ type ParamsOf,
47
+ type RouteMatch,
48
+ type RouterOptions,
49
+ type RouterProps,
50
+ type RouteTable,
51
+ } from './router'
52
+ export {
53
+ CountBadge,
54
+ type CountBadgeProps,
55
+ EmptyState,
56
+ type EmptyStateProps,
57
+ ErrorState,
58
+ type ErrorStateProps,
59
+ LoadingState,
60
+ type LoadingStateProps,
61
+ Spinner,
62
+ type SpinnerProps,
63
+ } from './states'
64
+ export { StatusPill, type StatusPillProps } from './StatusPill'
65
+ export { type TabItem, Tabs, type TabsProps } from './Tabs'
66
+ export { Timeline, type TimelineItem, type TimelineProps } from './Timeline'
67
+ export { type ToastApi, type ToastOptions, ToastProvider, type ToastProviderProps, type ToastTone, useToast } from './Toast'
68
+ export type { Tone } from './tone'
package/src/inputs.tsx ADDED
@@ -0,0 +1,164 @@
1
+ import { type ComponentProps, type ReactNode, useEffect, useId, useRef } from 'react'
2
+ import { classNames } from './class-names'
3
+ import { useFieldAttributes } from './Field'
4
+
5
+ export interface InputProps extends Readonly<ComponentProps<'input'>> {}
6
+
7
+ export function Input({ className, ...rest }: InputProps) {
8
+ const field = useFieldAttributes(rest)
9
+ return <input {...rest} {...field} className={classNames('fui-input', className)} />
10
+ }
11
+
12
+ export interface DateInputProps extends Readonly<Omit<ComponentProps<'input'>, 'type'>> {}
13
+
14
+ export function DateInput({ className, ...rest }: DateInputProps) {
15
+ const field = useFieldAttributes(rest)
16
+ return <input {...rest} {...field} type="date" className={classNames('fui-input', 'fui-input--date', className)} />
17
+ }
18
+
19
+ export interface TextareaProps extends Readonly<ComponentProps<'textarea'>> {}
20
+
21
+ export function Textarea({ className, ...rest }: TextareaProps) {
22
+ const field = useFieldAttributes(rest)
23
+ return <textarea {...rest} {...field} className={classNames('fui-input', 'fui-textarea', className)} />
24
+ }
25
+
26
+ export interface SelectOption {
27
+ readonly value: string
28
+ readonly label: string
29
+ }
30
+
31
+ export interface SelectProps extends Readonly<Omit<ComponentProps<'select'>, 'children'>> {
32
+ readonly options: readonly SelectOption[]
33
+ /** An empty-valued first option, such as "All statuses" in a filter or "Choose…" in a form. */
34
+ readonly placeholder?: string
35
+ }
36
+
37
+ export function Select({ options, placeholder, className, ...rest }: SelectProps) {
38
+ const field = useFieldAttributes(rest)
39
+ return (
40
+ <select {...rest} {...field} className={classNames('fui-input', 'fui-select', className)}>
41
+ {placeholder !== undefined && <option value="">{placeholder}</option>}
42
+ {options.map((option) => (
43
+ <option key={option.value} value={option.value}>
44
+ {option.label}
45
+ </option>
46
+ ))}
47
+ </select>
48
+ )
49
+ }
50
+
51
+ export interface DateRangeValue {
52
+ readonly from: string
53
+ readonly to: string
54
+ }
55
+
56
+ export interface DateRangeProps extends DateRangeValue {
57
+ readonly onChange: (next: DateRangeValue) => void
58
+ readonly fromLabel?: string
59
+ readonly toLabel?: string
60
+ readonly disabled?: boolean
61
+ readonly className?: string
62
+ }
63
+
64
+ export function DateRange({ from, to, onChange, fromLabel = 'From', toLabel = 'To', disabled, className }: DateRangeProps) {
65
+ const id = useId()
66
+ return (
67
+ <div className={classNames('fui-date-range', className)}>
68
+ <label className="fui-date-range__part" htmlFor={`${id}-from`}>
69
+ <span className="fui-date-range__label">{fromLabel}</span>
70
+ <input
71
+ id={`${id}-from`}
72
+ type="date"
73
+ className="fui-input fui-input--date"
74
+ value={from}
75
+ max={to === '' ? undefined : to}
76
+ disabled={disabled}
77
+ onChange={(event) => onChange({ from: event.currentTarget.value, to })}
78
+ />
79
+ </label>
80
+ <label className="fui-date-range__part" htmlFor={`${id}-to`}>
81
+ <span className="fui-date-range__label">{toLabel}</span>
82
+ <input
83
+ id={`${id}-to`}
84
+ type="date"
85
+ className="fui-input fui-input--date"
86
+ value={to}
87
+ min={from === '' ? undefined : from}
88
+ disabled={disabled}
89
+ onChange={(event) => onChange({ from, to: event.currentTarget.value })}
90
+ />
91
+ </label>
92
+ </div>
93
+ )
94
+ }
95
+
96
+ export interface CheckboxProps {
97
+ readonly label: ReactNode
98
+ readonly checked: boolean
99
+ readonly onChange: (checked: boolean) => void
100
+ readonly indeterminate?: boolean
101
+ readonly disabled?: boolean
102
+ readonly name?: string
103
+ readonly value?: string
104
+ /** Keeps the label for assistive technology only, as in a table's selection column. */
105
+ readonly hideLabel?: boolean
106
+ readonly className?: string
107
+ }
108
+
109
+ export function Checkbox({ label, checked, onChange, indeterminate = false, disabled, name, value, hideLabel = false, className }: CheckboxProps) {
110
+ const ref = useRef<HTMLInputElement>(null)
111
+ useEffect(() => {
112
+ if (ref.current) ref.current.indeterminate = indeterminate
113
+ }, [indeterminate])
114
+ return (
115
+ <label className={classNames('fui-checkbox', disabled && 'fui-checkbox--disabled', className)}>
116
+ <input
117
+ ref={ref}
118
+ type="checkbox"
119
+ className="fui-checkbox__input"
120
+ checked={checked}
121
+ disabled={disabled}
122
+ name={name}
123
+ value={value}
124
+ aria-checked={indeterminate ? 'mixed' : undefined}
125
+ onChange={(event) => onChange(event.currentTarget.checked)}
126
+ />
127
+ <span className={hideLabel ? 'fui-visually-hidden' : 'fui-checkbox__label'}>{label}</span>
128
+ </label>
129
+ )
130
+ }
131
+
132
+ export interface RadioGroupProps<V extends string> {
133
+ readonly label: ReactNode
134
+ readonly name: string
135
+ readonly options: readonly { readonly value: V; readonly label: ReactNode }[]
136
+ readonly value: V | undefined
137
+ readonly onChange: (value: V) => void
138
+ readonly disabled?: boolean
139
+ readonly inline?: boolean
140
+ readonly className?: string
141
+ }
142
+
143
+ export function RadioGroup<V extends string>({ label, name, options, value, onChange, disabled, inline = false, className }: RadioGroupProps<V>) {
144
+ return (
145
+ <fieldset className={classNames('fui-radio-group', inline && 'fui-radio-group--inline', className)} disabled={disabled}>
146
+ <legend className="fui-radio-group__legend">{label}</legend>
147
+ <div className="fui-radio-group__options">
148
+ {options.map((option) => (
149
+ <label key={option.value} className="fui-radio">
150
+ <input
151
+ type="radio"
152
+ className="fui-radio__input"
153
+ name={name}
154
+ value={option.value}
155
+ checked={option.value === value}
156
+ onChange={() => onChange(option.value)}
157
+ />
158
+ <span className="fui-radio__label">{option.label}</span>
159
+ </label>
160
+ ))}
161
+ </div>
162
+ </fieldset>
163
+ )
164
+ }
package/src/layout.tsx ADDED
@@ -0,0 +1,40 @@
1
+ import type { ReactNode } from 'react'
2
+ import { classNames } from './class-names'
3
+
4
+ export type Gap = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8
5
+
6
+ export interface StackProps {
7
+ readonly gap?: Gap
8
+ readonly children: ReactNode
9
+ readonly className?: string
10
+ }
11
+
12
+ export function Stack({ gap = 4, children, className }: StackProps) {
13
+ return <div className={classNames('fui-stack', `fui-gap-${gap}`, className)}>{children}</div>
14
+ }
15
+
16
+ export interface InlineProps {
17
+ readonly gap?: Gap
18
+ readonly align?: 'start' | 'center' | 'end' | 'baseline' | 'stretch'
19
+ readonly justify?: 'start' | 'center' | 'end' | 'between'
20
+ readonly wrap?: boolean
21
+ readonly children: ReactNode
22
+ readonly className?: string
23
+ }
24
+
25
+ export function Inline({ gap = 2, align = 'center', justify = 'start', wrap = true, children, className }: InlineProps) {
26
+ return (
27
+ <div
28
+ className={classNames(
29
+ 'fui-inline',
30
+ `fui-gap-${gap}`,
31
+ `fui-inline--align-${align}`,
32
+ `fui-inline--justify-${justify}`,
33
+ !wrap && 'fui-inline--nowrap',
34
+ className,
35
+ )}
36
+ >
37
+ {children}
38
+ </div>
39
+ )
40
+ }
package/src/router.tsx ADDED
@@ -0,0 +1,279 @@
1
+ import { type ComponentProps, type ReactNode, useEffect, useSyncExternalStore } from 'react'
2
+
3
+ type ParamNames<P extends string> = P extends `${string}:${infer Name}/${infer Rest}` ? Name | ParamNames<`/${Rest}`>
4
+ : P extends `${string}:${infer Name}` ? Name
5
+ : never
6
+
7
+ /** The params of a path pattern: `ParamsOf<'/deals/:id'>` is `{ readonly id: string }`. */
8
+ export type ParamsOf<P extends string> = { readonly [Name in ParamNames<P>]: string }
9
+
10
+ /** A route name → path pattern table, such as `{ deals: '/deals', deal: '/deals/:id' }`. */
11
+ export type RouteTable = Readonly<Record<string, string>>
12
+
13
+ type RouteName<R extends RouteTable> = keyof R & string
14
+
15
+ /** A pattern without params takes no params argument; one with params requires them. */
16
+ export type ParamsArgs<P extends string> = [ParamNames<P>] extends [never] ? [] : [params: ParamsOf<P>]
17
+
18
+ export type RouteMatch<R extends RouteTable> = {
19
+ [Name in RouteName<R>]: { readonly name: Name; readonly params: ParamsOf<R[Name]> }
20
+ }[RouteName<R>]
21
+
22
+ export interface RouterOptions {
23
+ /** The path the application is mounted under; it is stripped when matching and added by `href`. */
24
+ readonly base?: string
25
+ }
26
+
27
+ export interface NavigateOptions {
28
+ readonly replace?: boolean
29
+ }
30
+
31
+ export type LinkProps<R extends RouteTable, Name extends RouteName<R>> =
32
+ & Readonly<Omit<ComponentProps<'a'>, 'href'>>
33
+ & { readonly to: Name }
34
+ & ([ParamNames<R[Name]>] extends [never] ? { readonly params?: undefined } : { readonly params: ParamsOf<R[Name]> })
35
+
36
+ export interface RouterProps {
37
+ readonly children: ReactNode
38
+ }
39
+
40
+ export interface CreatedRouter<R extends RouteTable> {
41
+ readonly Router: (props: RouterProps) => ReactNode
42
+ readonly Link: <Name extends RouteName<R>>(props: LinkProps<R, Name>) => ReactNode
43
+ readonly useRoute: () => RouteMatch<R> | null
44
+ readonly href: <Name extends RouteName<R>>(name: Name, ...params: ParamsArgs<R[Name]>) => string
45
+ readonly navigate: <Name extends RouteName<R>>(name: Name, ...args: [...ParamsArgs<R[Name]>, options?: NavigateOptions]) => void
46
+ }
47
+
48
+ type Segment = { readonly kind: 'static'; readonly value: string } | { readonly kind: 'param'; readonly name: string }
49
+
50
+ interface CompiledRoute {
51
+ readonly name: string
52
+ readonly segments: readonly Segment[]
53
+ }
54
+
55
+ interface LocationSnapshot {
56
+ readonly pathname: string
57
+ /** Set by a navigation this router made, so the page scrolls to the top and focus moves to its heading. */
58
+ readonly announce: boolean
59
+ }
60
+
61
+ function splitPath(path: string): string[] {
62
+ return path.split('/').filter((segment) => segment !== '')
63
+ }
64
+
65
+ function compile(name: string, pattern: string): CompiledRoute {
66
+ if (!pattern.startsWith('/')) throw new Error(`route ${name}: pattern ${JSON.stringify(pattern)} must start with '/'`)
67
+ const seen = new Set<string>()
68
+ const segments = splitPath(pattern).map((segment): Segment => {
69
+ if (!segment.startsWith(':')) return { kind: 'static', value: segment }
70
+ const param = segment.slice(1)
71
+ if (param === '' || seen.has(param)) throw new Error(`route ${name}: pattern ${JSON.stringify(pattern)} has an empty or repeated param`)
72
+ seen.add(param)
73
+ return { kind: 'param', name: param }
74
+ })
75
+ return { name, segments }
76
+ }
77
+
78
+ /** A static segment outranks a param at the first position where two patterns differ: `/deals/new` beats `/deals/:id`. */
79
+ function bySpecificity(left: CompiledRoute, right: CompiledRoute): number {
80
+ const length = Math.min(left.segments.length, right.segments.length)
81
+ for (let index = 0; index < length; index++) {
82
+ const leftKind = left.segments[index]?.kind
83
+ const rightKind = right.segments[index]?.kind
84
+ if (leftKind !== rightKind) return leftKind === 'static' ? -1 : 1
85
+ }
86
+ return 0
87
+ }
88
+
89
+ function decodeSegment(segment: string): string | undefined {
90
+ try {
91
+ return decodeURIComponent(segment)
92
+ } catch {
93
+ return undefined
94
+ }
95
+ }
96
+
97
+ function matchSegments(route: CompiledRoute, parts: readonly string[]): Record<string, string> | undefined {
98
+ if (route.segments.length !== parts.length) return undefined
99
+ const params: Record<string, string> = {}
100
+ for (const [index, segment] of route.segments.entries()) {
101
+ const part = parts[index]
102
+ if (part === undefined) return undefined
103
+ if (segment.kind === 'static') {
104
+ if (segment.value !== part) return undefined
105
+ continue
106
+ }
107
+ const value = decodeSegment(part)
108
+ if (value === undefined) return undefined
109
+ params[segment.name] = value
110
+ }
111
+ return params
112
+ }
113
+
114
+ function normalizeBase(base: string): string {
115
+ const trimmed = base.replace(/\/+$/, '')
116
+ if (trimmed === '') return ''
117
+ return trimmed.startsWith('/') ? trimmed : `/${trimmed}`
118
+ }
119
+
120
+ function isStringRecord(value: unknown): value is Readonly<Record<string, string>> {
121
+ return typeof value === 'object' && value !== null && Object.values(value).every((entry) => typeof entry === 'string')
122
+ }
123
+
124
+ function isNavigateOptions(value: unknown): value is NavigateOptions {
125
+ return typeof value === 'object' && value !== null
126
+ }
127
+
128
+ /**
129
+ * A typed History API router over a route table. Every function it answers is typed from `routes`, and `Router` turns
130
+ * every plain same-origin `<a href>` under `base` into an in-app navigation.
131
+ */
132
+ export function createRouter<const R extends RouteTable>(routes: R, options: RouterOptions = {}): CreatedRouter<R> {
133
+ const base = normalizeBase(options.base ?? '/')
134
+ const compiled = Object.entries(routes).map(([name, pattern]) => compile(name, pattern)).sort(bySpecificity)
135
+ const byName = new Map(compiled.map((route) => [route.name, route]))
136
+ const listeners = new Set<() => void>()
137
+ let snapshot: LocationSnapshot | undefined
138
+
139
+ function isRouteMatch(value: { readonly name: string; readonly params: Readonly<Record<string, string>> }): value is RouteMatch<R> {
140
+ const route = byName.get(value.name)
141
+ if (route === undefined) return false
142
+ const names = route.segments.flatMap((segment) => segment.kind === 'param' ? [segment.name] : [])
143
+ return names.length === Object.keys(value.params).length && names.every((name) => Object.hasOwn(value.params, name))
144
+ }
145
+
146
+ function stripBase(pathname: string): string | undefined {
147
+ if (base === '') return pathname
148
+ if (pathname === base) return '/'
149
+ return pathname.startsWith(`${base}/`) ? pathname.slice(base.length) : undefined
150
+ }
151
+
152
+ function match(pathname: string): RouteMatch<R> | null {
153
+ const path = stripBase(pathname)
154
+ if (path === undefined) return null
155
+ const parts = splitPath(path)
156
+ for (const route of compiled) {
157
+ const params = matchSegments(route, parts)
158
+ if (params === undefined) continue
159
+ const found = { name: route.name, params }
160
+ if (isRouteMatch(found)) return found
161
+ }
162
+ return null
163
+ }
164
+
165
+ function buildHref(name: string, params: unknown): string {
166
+ const route = byName.get(name)
167
+ if (route === undefined) throw new Error(`unknown route ${JSON.stringify(name)}`)
168
+ const values = isStringRecord(params) ? params : {}
169
+ const path = route.segments.map((segment) => {
170
+ if (segment.kind === 'static') return segment.value
171
+ const value = Object.hasOwn(values, segment.name) ? values[segment.name] : undefined
172
+ if (value === undefined) throw new Error(`route ${name}: missing param ${segment.name}`)
173
+ return encodeURIComponent(value)
174
+ })
175
+ return `${base}/${path.join('/')}`
176
+ }
177
+
178
+ function hasParams(name: string): boolean {
179
+ return byName.get(name)?.segments.some((segment) => segment.kind === 'param') ?? false
180
+ }
181
+
182
+ function getSnapshot(): LocationSnapshot {
183
+ const pathname = window.location.pathname
184
+ if (snapshot === undefined || snapshot.pathname !== pathname) snapshot = { pathname, announce: false }
185
+ return snapshot
186
+ }
187
+
188
+ function notify(announce: boolean): void {
189
+ snapshot = { pathname: window.location.pathname, announce }
190
+ for (const listener of listeners) listener()
191
+ }
192
+
193
+ function subscribe(listener: () => void): () => void {
194
+ listeners.add(listener)
195
+ const onPopState = () => notify(false)
196
+ window.addEventListener('popstate', onPopState)
197
+ return () => {
198
+ listeners.delete(listener)
199
+ window.removeEventListener('popstate', onPopState)
200
+ }
201
+ }
202
+
203
+ function go(url: string, replace: boolean): void {
204
+ if (replace) window.history.replaceState(null, '', url)
205
+ else window.history.pushState(null, '', url)
206
+ notify(true)
207
+ }
208
+
209
+ function useLocation(): LocationSnapshot {
210
+ return useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
211
+ }
212
+
213
+ function interceptedUrl(event: globalThis.MouseEvent): URL | undefined {
214
+ if (event.defaultPrevented || event.button !== 0) return undefined
215
+ if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return undefined
216
+ const anchor = event.target instanceof Element ? event.target.closest('a') : null
217
+ if (!(anchor instanceof HTMLAnchorElement)) return undefined
218
+ const href = anchor.getAttribute('href')
219
+ const target = anchor.getAttribute('target')
220
+ if (href === null || anchor.hasAttribute('download') || anchor.getAttribute('data-router') === 'native') return undefined
221
+ if (target !== null && target !== '' && target !== '_self') return undefined
222
+ const url = new URL(href, document.baseURI)
223
+ if (url.origin !== window.location.origin || stripBase(url.pathname) === undefined) return undefined
224
+ const samePage = url.pathname === window.location.pathname && url.search === window.location.search
225
+ // A link to an anchor on the current page keeps the browser's own scroll to that anchor.
226
+ if (samePage && url.hash !== '') return undefined
227
+ return url
228
+ }
229
+
230
+ function Router({ children }: RouterProps): ReactNode {
231
+ const location = useLocation()
232
+ useEffect(() => {
233
+ const onClick = (event: globalThis.MouseEvent) => {
234
+ const url = interceptedUrl(event)
235
+ if (url === undefined) return
236
+ event.preventDefault()
237
+ go(`${url.pathname}${url.search}${url.hash}`, false)
238
+ }
239
+ document.addEventListener('click', onClick)
240
+ return () => document.removeEventListener('click', onClick)
241
+ }, [])
242
+ useEffect(() => {
243
+ if (!location.announce) return
244
+ window.scrollTo(0, 0)
245
+ const heading = document.querySelector('main h1[tabindex="-1"]') ?? document.querySelector('h1[tabindex="-1"]')
246
+ if (heading instanceof HTMLElement) heading.focus({ preventScroll: true })
247
+ }, [location])
248
+ return children
249
+ }
250
+
251
+ function useRoute(): RouteMatch<R> | null {
252
+ return match(useLocation().pathname)
253
+ }
254
+
255
+ function href<Name extends RouteName<R>>(name: Name, ...params: ParamsArgs<R[Name]>): string {
256
+ return buildHref(name, params[0])
257
+ }
258
+
259
+ function navigate<Name extends RouteName<R>>(name: Name, ...args: [...ParamsArgs<R[Name]>, options?: NavigateOptions]): void {
260
+ const list: readonly unknown[] = args
261
+ const params = hasParams(name) ? list[0] : undefined
262
+ const options = list[hasParams(name) ? 1 : 0]
263
+ go(buildHref(name, params), isNavigateOptions(options) && options.replace === true)
264
+ }
265
+
266
+ function Link<Name extends RouteName<R>>(props: LinkProps<R, Name>): ReactNode {
267
+ const { to, params, children, ...rest } = props
268
+ const location = useLocation()
269
+ const target = buildHref(to, params)
270
+ const current = splitPath(location.pathname).join('/') === splitPath(target).join('/')
271
+ return (
272
+ <a {...rest} href={target} aria-current={current ? 'page' : rest['aria-current']}>
273
+ {children}
274
+ </a>
275
+ )
276
+ }
277
+
278
+ return { Router, Link, useRoute, href, navigate }
279
+ }
package/src/states.tsx ADDED
@@ -0,0 +1,78 @@
1
+ import type { ReactNode } from 'react'
2
+ import { Button } from './Button'
3
+ import { classNames } from './class-names'
4
+
5
+ export interface SpinnerProps {
6
+ readonly label?: string
7
+ readonly className?: string
8
+ }
9
+
10
+ export function Spinner({ label = 'Loading', className }: SpinnerProps) {
11
+ return (
12
+ <span className={classNames('fui-spinner', className)} role="status">
13
+ <span className="fui-visually-hidden">{label}</span>
14
+ </span>
15
+ )
16
+ }
17
+
18
+ export interface CountBadgeProps {
19
+ readonly count: number
20
+ readonly className?: string
21
+ }
22
+
23
+ export function CountBadge({ count, className }: CountBadgeProps) {
24
+ return <span className={classNames('fui-count-badge', className)}>{count}</span>
25
+ }
26
+
27
+ export interface EmptyStateProps {
28
+ readonly title: ReactNode
29
+ readonly text?: ReactNode
30
+ readonly action?: ReactNode
31
+ readonly className?: string
32
+ }
33
+
34
+ export function EmptyState({ title, text, action, className }: EmptyStateProps) {
35
+ return (
36
+ <div className={classNames('fui-state', 'fui-state--empty', className)}>
37
+ <p className="fui-state__title">{title}</p>
38
+ {text !== undefined && <p className="fui-state__text">{text}</p>}
39
+ {action !== undefined && <div className="fui-state__action">{action}</div>}
40
+ </div>
41
+ )
42
+ }
43
+
44
+ export interface ErrorStateProps {
45
+ readonly title?: ReactNode
46
+ readonly text: ReactNode
47
+ readonly onRetry?: () => void
48
+ readonly retryLabel?: string
49
+ readonly className?: string
50
+ }
51
+
52
+ export function ErrorState({ title = 'Something went wrong', text, onRetry, retryLabel = 'Retry', className }: ErrorStateProps) {
53
+ return (
54
+ <div className={classNames('fui-state', 'fui-state--error', className)} role="alert">
55
+ <p className="fui-state__title">{title}</p>
56
+ <p className="fui-state__text">{text}</p>
57
+ {onRetry && (
58
+ <div className="fui-state__action">
59
+ <Button onClick={onRetry}>{retryLabel}</Button>
60
+ </div>
61
+ )}
62
+ </div>
63
+ )
64
+ }
65
+
66
+ export interface LoadingStateProps {
67
+ readonly text?: ReactNode
68
+ readonly className?: string
69
+ }
70
+
71
+ export function LoadingState({ text = 'Loading…', className }: LoadingStateProps) {
72
+ return (
73
+ <div className={classNames('fui-state', 'fui-state--loading', className)} role="status">
74
+ <span className="fui-spinner" aria-hidden="true" />
75
+ <p className="fui-state__text">{text}</p>
76
+ </div>
77
+ )
78
+ }
package/src/styles.css ADDED
@@ -0,0 +1,2 @@
1
+ @import './theme.css';
2
+ @import './components.css';