@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/README.md +22 -2
- package/package.json +35 -5
- package/src/AppShell.tsx +54 -0
- package/src/Button.tsx +47 -0
- package/src/Card.tsx +57 -0
- package/src/Columns.tsx +32 -0
- package/src/DataTable.tsx +224 -0
- package/src/Dialog.tsx +158 -0
- package/src/Field.tsx +91 -0
- package/src/FieldList.tsx +30 -0
- package/src/FilterBar.tsx +24 -0
- package/src/Form.tsx +58 -0
- package/src/PageHeader.tsx +35 -0
- package/src/StatusPill.tsx +18 -0
- package/src/Tabs.tsx +74 -0
- package/src/Timeline.tsx +37 -0
- package/src/Toast.tsx +80 -0
- package/src/class-names.ts +4 -0
- package/src/components.css +1312 -0
- package/src/index.ts +68 -0
- package/src/inputs.tsx +164 -0
- package/src/layout.tsx +40 -0
- package/src/router.tsx +279 -0
- package/src/states.tsx +78 -0
- package/src/styles.css +2 -0
- package/src/theme.css +125 -0
- package/src/tone.ts +1 -0
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
|
+
}
|
package/src/Timeline.tsx
ADDED
|
@@ -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
|
+
}
|