@fabrika/ui 0.0.0-stage → 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/Field.tsx ADDED
@@ -0,0 +1,91 @@
1
+ import { createContext, isValidElement, type ReactNode, useContext, useId } from 'react'
2
+ import { classNames } from './class-names'
3
+ import { useFormFieldError } from './Form'
4
+
5
+ /** What a `Field` hands the one control inside it. */
6
+ export interface FieldControl {
7
+ readonly id: string
8
+ readonly name: string | undefined
9
+ readonly describedBy: string | undefined
10
+ readonly invalid: boolean
11
+ readonly required: boolean
12
+ }
13
+
14
+ const FieldContext = createContext<FieldControl | undefined>(undefined)
15
+
16
+ export function useFieldControl(): FieldControl | undefined {
17
+ return useContext(FieldContext)
18
+ }
19
+
20
+ export interface FieldProps {
21
+ readonly label: ReactNode
22
+ readonly name?: string
23
+ readonly hint?: ReactNode
24
+ readonly error?: string
25
+ readonly required?: boolean
26
+ readonly children: ReactNode
27
+ readonly className?: string
28
+ }
29
+
30
+ /** The id the one control inside was given explicitly, so the label and the descriptions point at it. */
31
+ function explicitId(children: ReactNode): string | undefined {
32
+ if (!isValidElement(children) || typeof children.props !== 'object' || children.props === null) return undefined
33
+ const id: unknown = Reflect.get(children.props, 'id')
34
+ return typeof id === 'string' && id !== '' ? id : undefined
35
+ }
36
+
37
+ export function Field({ label, name, hint, error, required = false, children, className }: FieldProps) {
38
+ const generated = useId()
39
+ const id = explicitId(children) ?? generated
40
+ const formError = useFormFieldError(name)
41
+ const message = error ?? formError
42
+ const hintId = hint === undefined ? undefined : `${id}-hint`
43
+ const errorId = message === undefined ? undefined : `${id}-error`
44
+ const describedBy = [hintId, errorId].filter((value) => value !== undefined).join(' ') || undefined
45
+ const control: FieldControl = { id, name, describedBy, invalid: message !== undefined, required }
46
+ return (
47
+ <div className={classNames('fui-field', message !== undefined && 'fui-field--invalid', className)}>
48
+ <label className="fui-field__label" htmlFor={id}>
49
+ {label}
50
+ {required && (
51
+ <span className="fui-field__required" aria-hidden="true">
52
+ *
53
+ </span>
54
+ )}
55
+ </label>
56
+ <FieldContext.Provider value={control}>{children}</FieldContext.Provider>
57
+ {hint !== undefined && (
58
+ <p className="fui-field__hint" id={hintId}>
59
+ {hint}
60
+ </p>
61
+ )}
62
+ {message !== undefined && (
63
+ <p className="fui-field__error" id={errorId}>
64
+ {message}
65
+ </p>
66
+ )}
67
+ </div>
68
+ )
69
+ }
70
+
71
+ interface ControlAttributes {
72
+ readonly id?: string
73
+ readonly name?: string
74
+ readonly required?: boolean
75
+ readonly 'aria-describedby'?: string
76
+ readonly 'aria-invalid'?: boolean | 'true' | 'false' | 'grammar' | 'spelling'
77
+ }
78
+
79
+ /** The attributes a control takes from its `Field`; what the author passed explicitly wins. */
80
+ export function useFieldAttributes(own: ControlAttributes): ControlAttributes {
81
+ const field = useFieldControl()
82
+ if (field === undefined) return own
83
+ const describedBy = [own['aria-describedby'], field.describedBy].filter((value) => value !== undefined).join(' ') || undefined
84
+ return {
85
+ id: own.id ?? field.id,
86
+ name: own.name ?? field.name,
87
+ required: own.required ?? (field.required || undefined),
88
+ 'aria-describedby': describedBy,
89
+ 'aria-invalid': own['aria-invalid'] ?? (field.invalid || undefined),
90
+ }
91
+ }
@@ -0,0 +1,30 @@
1
+ import type { ReactNode } from 'react'
2
+ import { classNames } from './class-names'
3
+
4
+ export interface FieldListItem {
5
+ readonly label: ReactNode
6
+ readonly value: ReactNode
7
+ }
8
+
9
+ export interface FieldListProps {
10
+ readonly items: readonly FieldListItem[]
11
+ readonly columns?: 1 | 2 | 3
12
+ readonly className?: string
13
+ }
14
+
15
+ function isBlank(value: ReactNode): boolean {
16
+ return value === null || value === undefined || value === ''
17
+ }
18
+
19
+ export function FieldList({ items, columns = 1, className }: FieldListProps) {
20
+ return (
21
+ <dl className={classNames('fui-field-list', `fui-field-list--cols-${columns}`, className)}>
22
+ {items.map((item, index) => (
23
+ <div key={index} className="fui-field-list__item">
24
+ <dt className="fui-field-list__label">{item.label}</dt>
25
+ <dd className="fui-field-list__value">{isBlank(item.value) ? <span className="fui-field-list__blank">—</span> : item.value}</dd>
26
+ </div>
27
+ ))}
28
+ </dl>
29
+ )
30
+ }
@@ -0,0 +1,24 @@
1
+ import type { ReactNode } from 'react'
2
+ import { Button } from './Button'
3
+ import { classNames } from './class-names'
4
+
5
+ export interface FilterBarProps {
6
+ readonly children: ReactNode
7
+ readonly onReset?: () => void
8
+ readonly resetLabel?: string
9
+ readonly label?: string
10
+ readonly className?: string
11
+ }
12
+
13
+ export function FilterBar({ children, onReset, resetLabel = 'Reset filters', label = 'Filters', className }: FilterBarProps) {
14
+ return (
15
+ <div className={classNames('fui-filter-bar', className)} role="group" aria-label={label}>
16
+ <div className="fui-filter-bar__filters">{children}</div>
17
+ {onReset && (
18
+ <Button variant="ghost" size="sm" onClick={onReset}>
19
+ {resetLabel}
20
+ </Button>
21
+ )}
22
+ </div>
23
+ )
24
+ }
package/src/Form.tsx ADDED
@@ -0,0 +1,58 @@
1
+ import { createContext, type FormEvent, type ReactNode, useContext } from 'react'
2
+ import { classNames } from './class-names'
3
+
4
+ /**
5
+ * The error a form shows. Structural on purpose: the framework's page hooks answer an error union whose members all
6
+ * carry a `message`, and the validation member carries `fields` by input path — it fits without this kit importing it.
7
+ */
8
+ export interface FormErrorLike {
9
+ readonly message: string
10
+ readonly fields?: Readonly<Record<string, string>>
11
+ }
12
+
13
+ const FormFieldErrorsContext = createContext<Readonly<Record<string, string>> | undefined>(undefined)
14
+
15
+ /** The message the enclosing `Form` holds for the field `name`, if any. */
16
+ export function useFormFieldError(name: string | undefined): string | undefined {
17
+ const fields = useContext(FormFieldErrorsContext)
18
+ if (name === undefined || fields === undefined) return undefined
19
+ return Object.hasOwn(fields, name) ? fields[name] : undefined
20
+ }
21
+
22
+ export interface FormProps {
23
+ readonly onSubmit: (data: FormData, form: HTMLFormElement) => void
24
+ readonly error?: FormErrorLike | null
25
+ readonly children: ReactNode
26
+ readonly className?: string
27
+ readonly fieldsSummary?: string
28
+ readonly 'aria-label'?: string
29
+ }
30
+
31
+ export function Form({ onSubmit, error, children, className, fieldsSummary = 'Check the highlighted fields.', ...rest }: FormProps) {
32
+ const fields = error?.fields
33
+ const hasFieldErrors = fields !== undefined && Object.keys(fields).length > 0
34
+ const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
35
+ event.preventDefault()
36
+ const form = event.currentTarget
37
+ onSubmit(new FormData(form), form)
38
+ }
39
+ return (
40
+ <form noValidate className={classNames('fui-form', className)} onSubmit={handleSubmit} aria-label={rest['aria-label']}>
41
+ {error && (
42
+ <div className="fui-form__summary" role="alert">
43
+ {hasFieldErrors ? fieldsSummary : error.message}
44
+ </div>
45
+ )}
46
+ <FormFieldErrorsContext.Provider value={fields}>{children}</FormFieldErrorsContext.Provider>
47
+ </form>
48
+ )
49
+ }
50
+
51
+ export interface FormActionsProps {
52
+ readonly children: ReactNode
53
+ readonly className?: string
54
+ }
55
+
56
+ export function FormActions({ children, className }: FormActionsProps) {
57
+ return <div className={classNames('fui-form-actions', className)}>{children}</div>
58
+ }
@@ -0,0 +1,35 @@
1
+ import type { ReactNode } from 'react'
2
+ import { classNames } from './class-names'
3
+
4
+ export interface PageHeaderProps {
5
+ readonly title: ReactNode
6
+ readonly lead?: ReactNode
7
+ readonly back?: { readonly href: string; readonly label: string }
8
+ readonly actions?: ReactNode
9
+ readonly meta?: ReactNode
10
+ readonly className?: string
11
+ }
12
+
13
+ export function PageHeader({ title, lead, back, actions, meta, className }: PageHeaderProps) {
14
+ return (
15
+ <div className={classNames('fui-page-header', className)}>
16
+ {back !== undefined && (
17
+ <a className="fui-page-header__back" href={back.href}>
18
+ <span aria-hidden="true">←</span>
19
+ {back.label}
20
+ </a>
21
+ )}
22
+ <div className="fui-page-header__row">
23
+ <div className="fui-page-header__heading">
24
+ {/* The router moves focus here after a navigation, so a screen reader announces the new page. */}
25
+ <h1 className="fui-page-header__title" tabIndex={-1}>
26
+ {title}
27
+ </h1>
28
+ {lead !== undefined && <p className="fui-page-header__lead">{lead}</p>}
29
+ </div>
30
+ {actions !== undefined && <div className="fui-page-header__actions">{actions}</div>}
31
+ </div>
32
+ {meta !== undefined && <div className="fui-page-header__meta">{meta}</div>}
33
+ </div>
34
+ )
35
+ }
@@ -0,0 +1,18 @@
1
+ import type { ReactNode } from 'react'
2
+ import { classNames } from './class-names'
3
+ import type { Tone } from './tone'
4
+
5
+ export interface StatusPillProps {
6
+ readonly tone?: Tone
7
+ readonly children: ReactNode
8
+ readonly className?: string
9
+ }
10
+
11
+ export function StatusPill({ tone = 'neutral', children, className }: StatusPillProps) {
12
+ return (
13
+ <span className={classNames('fui-pill', `fui-pill--${tone}`, className)}>
14
+ <span className="fui-pill__dot" aria-hidden="true" />
15
+ {children}
16
+ </span>
17
+ )
18
+ }
package/src/Tabs.tsx ADDED
@@ -0,0 +1,74 @@
1
+ import { type KeyboardEvent, useRef } from 'react'
2
+ import { classNames } from './class-names'
3
+ import { CountBadge } from './states'
4
+
5
+ export interface TabItem<K extends string> {
6
+ readonly key: K
7
+ readonly label: string
8
+ readonly count?: number
9
+ }
10
+
11
+ export interface TabsProps<K extends string> {
12
+ readonly label: string
13
+ readonly items: readonly TabItem<K>[]
14
+ readonly active: K
15
+ readonly onChange: (key: K) => void
16
+ readonly className?: string
17
+ }
18
+
19
+ function targetIndex(key: string, current: number, length: number): number | undefined {
20
+ switch (key) {
21
+ case 'ArrowRight':
22
+ return (current + 1) % length
23
+ case 'ArrowLeft':
24
+ return (current - 1 + length) % length
25
+ case 'Home':
26
+ return 0
27
+ case 'End':
28
+ return length - 1
29
+ default:
30
+ return undefined
31
+ }
32
+ }
33
+
34
+ /** A WAI-ARIA tab list with automatic activation: moving focus with the keyboard also selects. */
35
+ export function Tabs<K extends string>({ label, items, active, onChange, className }: TabsProps<K>) {
36
+ const tabs = useRef(new Map<K, HTMLButtonElement>())
37
+ const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
38
+ const current = items.findIndex((item) => tabs.current.get(item.key) === event.target)
39
+ if (current === -1) return
40
+ const next = targetIndex(event.key, current, items.length)
41
+ const item = next === undefined ? undefined : items[next]
42
+ if (item === undefined) return
43
+ event.preventDefault()
44
+ tabs.current.get(item.key)?.focus()
45
+ if (item.key !== active) onChange(item.key)
46
+ }
47
+ return (
48
+ <div className={classNames('fui-tabs', className)} role="tablist" aria-label={label} onKeyDown={handleKeyDown}>
49
+ {items.map((item) => {
50
+ const selected = item.key === active
51
+ return (
52
+ <button
53
+ key={item.key}
54
+ ref={(element) => {
55
+ if (element) tabs.current.set(item.key, element)
56
+ else tabs.current.delete(item.key)
57
+ }}
58
+ type="button"
59
+ role="tab"
60
+ aria-selected={selected}
61
+ tabIndex={selected ? 0 : -1}
62
+ className={classNames('fui-tabs__tab', selected && 'fui-tabs__tab--active')}
63
+ onClick={() => {
64
+ if (!selected) onChange(item.key)
65
+ }}
66
+ >
67
+ {item.label}
68
+ {item.count !== undefined && <CountBadge count={item.count} />}
69
+ </button>
70
+ )
71
+ })}
72
+ </div>
73
+ )
74
+ }
@@ -0,0 +1,37 @@
1
+ import type { ReactNode } from 'react'
2
+ import { classNames } from './class-names'
3
+ import type { Tone } from './tone'
4
+
5
+ export interface TimelineItem {
6
+ readonly id: string
7
+ readonly at: ReactNode
8
+ readonly title: ReactNode
9
+ readonly body?: ReactNode
10
+ readonly tone?: Tone
11
+ }
12
+
13
+ export interface TimelineProps {
14
+ readonly items: readonly TimelineItem[]
15
+ readonly empty?: ReactNode
16
+ readonly className?: string
17
+ }
18
+
19
+ export function Timeline({ items, empty = 'Nothing here yet', className }: TimelineProps) {
20
+ if (items.length === 0) return <p className={classNames('fui-timeline-empty', className)}>{empty}</p>
21
+ return (
22
+ <ol className={classNames('fui-timeline', className)}>
23
+ {items.map((item) => (
24
+ <li key={item.id} className={classNames('fui-timeline__item', `fui-timeline__item--${item.tone ?? 'neutral'}`)}>
25
+ <span className="fui-timeline__marker" aria-hidden="true" />
26
+ <div className="fui-timeline__content">
27
+ <div className="fui-timeline__head">
28
+ <span className="fui-timeline__title">{item.title}</span>
29
+ <span className="fui-timeline__at">{item.at}</span>
30
+ </div>
31
+ {item.body !== undefined && <div className="fui-timeline__body">{item.body}</div>}
32
+ </div>
33
+ </li>
34
+ ))}
35
+ </ol>
36
+ )
37
+ }
package/src/Toast.tsx ADDED
@@ -0,0 +1,80 @@
1
+ import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
2
+ import { classNames } from './class-names'
3
+
4
+ export type ToastTone = 'neutral' | 'success' | 'danger'
5
+
6
+ export interface ToastOptions {
7
+ readonly tone?: ToastTone
8
+ readonly message: string
9
+ /** How long the toast stays; `0` keeps it until it is dismissed. */
10
+ readonly timeoutMs?: number
11
+ }
12
+
13
+ export interface ToastApi {
14
+ readonly show: (options: ToastOptions) => void
15
+ }
16
+
17
+ interface ToastEntry {
18
+ readonly id: number
19
+ readonly tone: ToastTone
20
+ readonly message: string
21
+ }
22
+
23
+ const ToastContext = createContext<ToastApi | undefined>(undefined)
24
+
25
+ export interface ToastProviderProps {
26
+ readonly children: ReactNode
27
+ readonly dismissLabel?: string
28
+ readonly regionLabel?: string
29
+ }
30
+
31
+ export function ToastProvider({ children, dismissLabel = 'Dismiss', regionLabel = 'Notifications' }: ToastProviderProps) {
32
+ const [toasts, setToasts] = useState<readonly ToastEntry[]>([])
33
+ const nextId = useRef(1)
34
+ const timers = useRef(new Map<number, ReturnType<typeof setTimeout>>())
35
+
36
+ const dismiss = useCallback((id: number) => {
37
+ const timer = timers.current.get(id)
38
+ if (timer !== undefined) clearTimeout(timer)
39
+ timers.current.delete(id)
40
+ setToasts((current) => current.filter((toast) => toast.id !== id))
41
+ }, [])
42
+
43
+ const show = useCallback(({ tone = 'neutral', message, timeoutMs = 5000 }: ToastOptions) => {
44
+ const id = nextId.current++
45
+ setToasts((current) => [...current, { id, tone, message }])
46
+ if (timeoutMs > 0) timers.current.set(id, setTimeout(() => dismiss(id), timeoutMs))
47
+ }, [dismiss])
48
+
49
+ useEffect(() => {
50
+ const pending = timers.current
51
+ return () => {
52
+ for (const timer of pending.values()) clearTimeout(timer)
53
+ pending.clear()
54
+ }
55
+ }, [])
56
+
57
+ const api = useMemo<ToastApi>(() => ({ show }), [show])
58
+
59
+ return (
60
+ <ToastContext.Provider value={api}>
61
+ {children}
62
+ <div className="fui-toast-region" role="status" aria-live="polite" aria-label={regionLabel}>
63
+ {toasts.map((toast) => (
64
+ <div key={toast.id} className={classNames('fui-toast', `fui-toast--${toast.tone}`)}>
65
+ <span className="fui-toast__message">{toast.message}</span>
66
+ <button type="button" className="fui-toast__dismiss" aria-label={dismissLabel} onClick={() => dismiss(toast.id)}>
67
+ <span aria-hidden="true">×</span>
68
+ </button>
69
+ </div>
70
+ ))}
71
+ </div>
72
+ </ToastContext.Provider>
73
+ )
74
+ }
75
+
76
+ export function useToast(): ToastApi {
77
+ const api = useContext(ToastContext)
78
+ if (api === undefined) throw new Error('useToast() must be called inside a <ToastProvider>; wrap the application root in one.')
79
+ return api
80
+ }
@@ -0,0 +1,4 @@
1
+ /** Joins the truthy class names: `classNames('fui-button', primary && 'fui-button--primary', className)`. */
2
+ export function classNames(...names: readonly (string | false | null | undefined)[]): string {
3
+ return names.filter((name) => typeof name === 'string' && name !== '').join(' ')
4
+ }