@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/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/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