@glyphui/ui 0.3.0 → 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 +65 -12
- package/dist/chart.d.ts +1 -1
- package/dist/chart.js +28 -19
- package/dist/color-mode-script-Bf7LYkOj.js +13 -0
- package/dist/color-mode-script-KdwItKN2.d.ts +16 -0
- package/dist/data-table.js +3 -3
- package/dist/{field-CWZ_FdkD.js → field-CVbywksE.js} +21 -6
- package/dist/{field-context-anGPYXbW.js → field-context-7I-cWvvS.js} +2 -2
- package/dist/form.d.ts +11 -4
- package/dist/form.js +34 -9
- package/dist/glyph-context-DxGJrdvr.d.ts +50 -0
- package/dist/{index-BWK4czIG.d.ts → index-Bi7rZ6ba.d.ts} +11 -0
- package/dist/index.d.ts +87 -80
- package/dist/index.js +155 -82
- package/dist/react-router.d.ts +18 -0
- package/dist/react-router.js +44 -0
- package/dist/{states-DR-OoDT-.js → states-IvhjBgna.js} +2 -2
- package/dist/{tv-DBAGlgXJ.js → tv-DLbT66dC.js} +13 -1
- package/dist/vite.d.ts +27 -0
- package/dist/vite.js +29 -0
- package/fonts.css +10 -0
- package/package.json +32 -5
- package/src/components/badge/badge.styles.ts +11 -16
- package/src/components/banner/banner.styles.ts +4 -4
- package/src/components/button/button.styles.ts +3 -1
- package/src/components/calendar/calendar.styles.ts +3 -2
- package/src/components/dropdown-menu/dropdown-menu.styles.ts +2 -2
- package/src/components/eyebrow/eyebrow.styles.ts +1 -1
- package/src/components/field/field-context.ts +2 -0
- package/src/components/field/field.styles.ts +4 -2
- package/src/components/field/field.tsx +25 -4
- package/src/components/file-upload/file-upload.styles.ts +3 -3
- package/src/components/link/link.styles.ts +1 -1
- package/src/components/modal/modal.styles.ts +2 -0
- package/src/components/modal/modal.tsx +30 -5
- package/src/components/navigation-menu/navigation-menu.tsx +14 -4
- package/src/components/password-input/password-input.styles.ts +3 -3
- package/src/components/prose/prose.tsx +1 -1
- package/src/components/scroll-area/scroll-area.tsx +23 -1
- package/src/components/segmented-control/segmented-control.tsx +17 -2
- package/src/components/slider/slider.tsx +9 -2
- package/src/components/stepper/stepper.styles.ts +2 -2
- package/src/components/tag-input/tag-input.styles.ts +1 -1
- package/src/components/text/text.styles.ts +4 -4
- package/src/integrations/react-router.tsx +39 -0
- package/src/integrations/vite.test-d.ts +6 -0
- package/src/integrations/vite.test.ts +28 -0
- package/src/integrations/vite.ts +33 -0
- package/src/internal/dev.ts +12 -0
- package/src/patterns/app-shell/app-shell.styles.ts +1 -1
- package/src/patterns/app-shell/navbar.tsx +34 -16
- package/src/patterns/app-shell/sidebar.tsx +7 -2
- package/src/patterns/chart/chart-parts.tsx +10 -2
- package/src/patterns/chart/chart.tsx +22 -5
- package/src/patterns/form/form.tsx +48 -6
- package/src/patterns/form/index.ts +1 -0
- package/src/provider/color-mode-script.ts +28 -0
- package/src/provider/color-mode.tsx +14 -20
- package/src/provider/glyph-context.ts +38 -0
- package/src/provider/glyph-provider.tsx +16 -4
- package/src/provider/index.ts +4 -0
- package/src/provider/theme-scope.tsx +7 -3
- package/styles.css +72 -0
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { ToggleGroup } from 'radix-ui'
|
|
2
|
-
import type
|
|
2
|
+
import { type ReactNode, useContext } from 'react'
|
|
3
3
|
import { useComponentConfig } from '../../hooks/use-component-config'
|
|
4
|
+
import { warnOnce } from '../../internal/dev'
|
|
4
5
|
import { focusRing, themedBorder, transition } from '../../utils/styles'
|
|
5
6
|
import { tv, type VariantProps } from '../../utils/tv'
|
|
7
|
+
import { FieldContext } from '../field/field-context'
|
|
6
8
|
|
|
7
9
|
export const segmentedControlStyles = tv({
|
|
8
10
|
slots: {
|
|
@@ -40,7 +42,9 @@ export interface SegmentedControlProps<T extends string>
|
|
|
40
42
|
value?: T
|
|
41
43
|
defaultValue?: T
|
|
42
44
|
onValueChange?: (value: T) => void
|
|
43
|
-
|
|
45
|
+
/** Required unless the control sits in a `<Field label>`, which labels it. */
|
|
46
|
+
'aria-label'?: string
|
|
47
|
+
'aria-labelledby'?: string
|
|
44
48
|
className?: string
|
|
45
49
|
id?: string
|
|
46
50
|
}
|
|
@@ -52,7 +56,16 @@ export interface SegmentedControlProps<T extends string>
|
|
|
52
56
|
export function SegmentedControl<T extends string>(inProps: SegmentedControlProps<T>) {
|
|
53
57
|
const [props, slots] = useComponentConfig('SegmentedControl', inProps)
|
|
54
58
|
const { options, value, defaultValue, onValueChange, size, fullWidth, className, ...rest } = props
|
|
59
|
+
const field = useContext(FieldContext)
|
|
55
60
|
const s = segmentedControlStyles({ size, fullWidth })
|
|
61
|
+
const labelledBy = rest['aria-labelledby'] ?? (rest['aria-label'] ? undefined : field?.labelId)
|
|
62
|
+
if (!rest['aria-label'] && !labelledBy) {
|
|
63
|
+
warnOnce(
|
|
64
|
+
'segmented-control-label',
|
|
65
|
+
'SegmentedControl needs a label: pass aria-label, or place it in a <Field label="…">.',
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
const describedBy = [field?.descriptionId, field?.errorId].filter(Boolean).join(' ')
|
|
56
69
|
return (
|
|
57
70
|
<ToggleGroup.Root
|
|
58
71
|
type="single"
|
|
@@ -62,6 +75,8 @@ export function SegmentedControl<T extends string>(inProps: SegmentedControlProp
|
|
|
62
75
|
// Radix emits '' when the active item is clicked again; a segmented control never deselects.
|
|
63
76
|
onValueChange={(next: string) => next && onValueChange?.(next as T)}
|
|
64
77
|
className={s.root({ class: [slots.root, className] })}
|
|
78
|
+
aria-labelledby={labelledBy}
|
|
79
|
+
aria-describedby={describedBy || undefined}
|
|
65
80
|
{...rest}
|
|
66
81
|
>
|
|
67
82
|
{options.map((option) => (
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Slider as SliderPrimitive } from 'radix-ui'
|
|
2
|
-
import type
|
|
2
|
+
import { type ReactNode, useContext } from 'react'
|
|
3
3
|
import { useComponentConfig } from '../../hooks/use-component-config'
|
|
4
|
-
import { useFieldControl } from '../field/field-context'
|
|
4
|
+
import { FieldContext, useFieldControl } from '../field/field-context'
|
|
5
5
|
import { sliderStyles } from './slider.styles'
|
|
6
6
|
|
|
7
7
|
export type SliderValue = number | readonly [number, number]
|
|
@@ -31,7 +31,9 @@ export interface SliderProps<V extends SliderValue> {
|
|
|
31
31
|
name?: string
|
|
32
32
|
id?: string
|
|
33
33
|
className?: string
|
|
34
|
+
/** Accessible name. Inside a `Field`, the field's label is used when this is not set. */
|
|
34
35
|
'aria-label'?: string
|
|
36
|
+
'aria-labelledby'?: string
|
|
35
37
|
'aria-describedby'?: string
|
|
36
38
|
'aria-invalid'?: boolean
|
|
37
39
|
/** Accessible names for range thumbs. Default: "Minimum", "Maximum". */
|
|
@@ -61,9 +63,13 @@ export function Slider<V extends SliderValue = number>(inProps: SliderProps<V>)
|
|
|
61
63
|
className,
|
|
62
64
|
thumbLabels = ['Minimum', 'Maximum'],
|
|
63
65
|
'aria-label': ariaLabel,
|
|
66
|
+
'aria-labelledby': ariaLabelledBy,
|
|
64
67
|
'aria-describedby': ariaDescribedBy,
|
|
65
68
|
'aria-invalid': ariaInvalid,
|
|
66
69
|
} = props
|
|
70
|
+
const field = useContext(FieldContext)
|
|
71
|
+
// Thumbs are not labelable elements, so a Field's <label for> can't name them.
|
|
72
|
+
const labelledBy = ariaLabelledBy ?? (ariaLabel ? undefined : field?.labelId)
|
|
67
73
|
const isRange = Array.isArray(value ?? defaultValue)
|
|
68
74
|
const fromArray = (next: number[]) => (isRange ? [next[0], next[1]] : next[0]) as V
|
|
69
75
|
const current = toArray(value) ?? toArray(defaultValue) ?? [min]
|
|
@@ -97,6 +103,7 @@ export function Slider<V extends SliderValue = number>(inProps: SliderProps<V>)
|
|
|
97
103
|
key={index}
|
|
98
104
|
id={index === 0 ? id : undefined}
|
|
99
105
|
aria-label={isRange ? thumbLabels[index] : ariaLabel}
|
|
106
|
+
aria-labelledby={isRange ? undefined : labelledBy}
|
|
100
107
|
aria-describedby={ariaDescribedBy}
|
|
101
108
|
aria-invalid={ariaInvalid}
|
|
102
109
|
className={s.thumb({ class: slots.thumb })}
|
|
@@ -41,7 +41,7 @@ export const stepperStyles = tv({
|
|
|
41
41
|
title: 'text-foreground',
|
|
42
42
|
},
|
|
43
43
|
current: {
|
|
44
|
-
indicator: 'border-primary bg-surface text-primary ring-4 ring-primary/15',
|
|
44
|
+
indicator: 'border-primary bg-surface text-primary-text ring-4 ring-primary/15',
|
|
45
45
|
connector: 'bg-border',
|
|
46
46
|
title: 'text-foreground',
|
|
47
47
|
},
|
|
@@ -53,7 +53,7 @@ export const stepperStyles = tv({
|
|
|
53
53
|
error: {
|
|
54
54
|
indicator: 'border-danger bg-danger text-danger-foreground',
|
|
55
55
|
connector: 'bg-border',
|
|
56
|
-
title: 'text-danger',
|
|
56
|
+
title: 'text-danger-text',
|
|
57
57
|
},
|
|
58
58
|
},
|
|
59
59
|
clickable: {
|
|
@@ -18,7 +18,7 @@ export const tagInputStyles = tv({
|
|
|
18
18
|
'inline-flex size-4 shrink-0 cursor-pointer items-center justify-center rounded-pill text-muted-foreground hover:bg-border-strong/40 hover:text-foreground focus-visible:outline-2 focus-visible:outline-ring [&>svg]:size-3',
|
|
19
19
|
input:
|
|
20
20
|
'h-7 min-w-24 flex-1 bg-transparent px-1 text-foreground outline-none placeholder:text-faint-foreground disabled:cursor-not-allowed',
|
|
21
|
-
message: 'text-small font-medium text-danger',
|
|
21
|
+
message: 'text-small font-medium text-danger-text',
|
|
22
22
|
},
|
|
23
23
|
variants: {
|
|
24
24
|
size: {
|
|
@@ -13,10 +13,10 @@ export const textStyles = tv({
|
|
|
13
13
|
default: { base: 'text-foreground' },
|
|
14
14
|
muted: { base: 'text-muted-foreground' },
|
|
15
15
|
faint: { base: 'text-faint-foreground' },
|
|
16
|
-
primary: { base: 'text-primary' },
|
|
17
|
-
success: { base: 'text-success' },
|
|
18
|
-
warning: { base: 'text-warning' },
|
|
19
|
-
danger: { base: 'text-danger' },
|
|
16
|
+
primary: { base: 'text-primary-text' },
|
|
17
|
+
success: { base: 'text-success-text' },
|
|
18
|
+
warning: { base: 'text-warning-text' },
|
|
19
|
+
danger: { base: 'text-danger-text' },
|
|
20
20
|
},
|
|
21
21
|
weight: {
|
|
22
22
|
regular: { base: 'font-normal' },
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { Link, useLocation } from 'react-router'
|
|
2
|
+
import type { GlyphRouter, IsActiveOptions, LinkComponentProps } from '../provider/glyph-context'
|
|
3
|
+
|
|
4
|
+
const isOutsideApp = (href: string) =>
|
|
5
|
+
href.startsWith('#') || href.startsWith('//') || /^[a-z][a-z\d+.-]*:/i.test(href)
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* React Router's `Link`, taking Glyph's `href` prop. Hash links, external URLs and `mailto:`
|
|
9
|
+
* stay plain anchors, so in-page anchors scroll and external links leave the app.
|
|
10
|
+
*/
|
|
11
|
+
export function RouterLink({ href, ...props }: LinkComponentProps) {
|
|
12
|
+
if (isOutsideApp(href)) return <a href={href} {...props} />
|
|
13
|
+
return <Link to={href} {...props} />
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const trimSlash = (path: string) => (path.length > 1 ? path.replace(/\/+$/, '') : path)
|
|
17
|
+
|
|
18
|
+
/** Whether an in-app `href` matches a pathname, with React Router's `NavLink` semantics. */
|
|
19
|
+
export function matchPath(href: string, pathname: string, { end = false }: IsActiveOptions = {}) {
|
|
20
|
+
// Hash links, external URLs and protocol links never mark an item active.
|
|
21
|
+
if (!href || isOutsideApp(href)) return false
|
|
22
|
+
const target = trimSlash(new URL(href, 'http://glyph.local').pathname)
|
|
23
|
+
const current = trimSlash(pathname)
|
|
24
|
+
if (end || target === '/') return current === target
|
|
25
|
+
return current === target || current.startsWith(`${target}/`)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Marks a link active when the current location matches it, like React Router's `NavLink`. */
|
|
29
|
+
export function useRouterIsActive(href: string, options?: IsActiveOptions): boolean {
|
|
30
|
+
const { pathname } = useLocation()
|
|
31
|
+
return matchPath(href, pathname, options)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Connects Glyph's navigation to React Router (v7 or later):
|
|
36
|
+
* `<GlyphProvider router={reactRouter}>`. Links render client-side, and Sidebar, Navbar
|
|
37
|
+
* and NavigationMenu items mark the current page automatically. Must render inside a router.
|
|
38
|
+
*/
|
|
39
|
+
export const reactRouter: GlyphRouter = { Link: RouterLink, useIsActive: useRouterIsActive }
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { getColorModeScript } from '../provider/color-mode-script'
|
|
3
|
+
import { glyphColorMode } from './vite'
|
|
4
|
+
|
|
5
|
+
describe('glyphColorMode', () => {
|
|
6
|
+
it('prepends the color-mode script to the head before other transforms', () => {
|
|
7
|
+
const plugin = glyphColorMode()
|
|
8
|
+
expect(plugin.name).toBe('glyph:color-mode')
|
|
9
|
+
expect(plugin.transformIndexHtml.order).toBe('pre')
|
|
10
|
+
expect(plugin.transformIndexHtml.handler()).toEqual([
|
|
11
|
+
{ tag: 'script', children: getColorModeScript(), injectTo: 'head-prepend' },
|
|
12
|
+
])
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('uses the same options as GlyphProvider', () => {
|
|
16
|
+
const [tag] = glyphColorMode({
|
|
17
|
+
defaultMode: 'dark',
|
|
18
|
+
storageKey: 'acme-mode',
|
|
19
|
+
}).transformIndexHtml.handler()
|
|
20
|
+
expect(tag?.children).toContain('"acme-mode"')
|
|
21
|
+
expect(tag?.children).toContain('d="dark"')
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('skips localStorage when persistence is off', () => {
|
|
25
|
+
const [tag] = glyphColorMode({ storageKey: null }).transformIndexHtml.handler()
|
|
26
|
+
expect(tag?.children).not.toContain('localStorage')
|
|
27
|
+
})
|
|
28
|
+
})
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { type ColorModeOptions, getColorModeScript } from '../provider/color-mode-script'
|
|
2
|
+
|
|
3
|
+
/** The subset of Vite's `Plugin` this plugin uses, so `vite` stays an optional peer. */
|
|
4
|
+
export interface GlyphVitePlugin {
|
|
5
|
+
name: string
|
|
6
|
+
transformIndexHtml: {
|
|
7
|
+
order: 'pre'
|
|
8
|
+
handler: () => { tag: 'script'; children: string; injectTo: 'head-prepend' }[]
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Injects Glyph's color-mode script into `index.html` so dark-mode visitors never see a
|
|
14
|
+
* light flash. Pass the same `defaultMode` and `storageKey` as `GlyphProvider`.
|
|
15
|
+
*
|
|
16
|
+
* ```ts
|
|
17
|
+
* // vite.config.ts
|
|
18
|
+
* import { glyphColorMode } from '@glyphui/ui/vite'
|
|
19
|
+
* export default defineConfig({ plugins: [react(), tailwindcss(), glyphColorMode()] })
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
export function glyphColorMode(options: ColorModeOptions = {}): GlyphVitePlugin {
|
|
23
|
+
const script = getColorModeScript(options)
|
|
24
|
+
return {
|
|
25
|
+
name: 'glyph:color-mode',
|
|
26
|
+
transformIndexHtml: {
|
|
27
|
+
order: 'pre',
|
|
28
|
+
handler: () => [{ tag: 'script', children: script, injectTo: 'head-prepend' }],
|
|
29
|
+
},
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type { ColorModeOptions }
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Bundlers replace process.env.NODE_ENV; the typeof guard keeps unbundled browsers safe.
|
|
2
|
+
declare const process: { env: { NODE_ENV?: string } } | undefined
|
|
3
|
+
|
|
4
|
+
const warned = new Set<string>()
|
|
5
|
+
|
|
6
|
+
/** Logs a console warning once per key, in development only. */
|
|
7
|
+
export function warnOnce(key: string, message: string): void {
|
|
8
|
+
if (typeof process === 'undefined' || process.env.NODE_ENV === 'production') return
|
|
9
|
+
if (warned.has(key)) return
|
|
10
|
+
warned.add(key)
|
|
11
|
+
console.warn(`[glyph] ${message}`)
|
|
12
|
+
}
|
|
@@ -55,7 +55,7 @@ export const sidebarStyles = tv({
|
|
|
55
55
|
'group-data-[collapsed=true]/sidebar:justify-center group-data-[collapsed=true]/sidebar:px-0',
|
|
56
56
|
],
|
|
57
57
|
itemIcon: 'flex size-4 shrink-0 items-center justify-center [&>svg]:size-4',
|
|
58
|
-
itemLabel: 'min-w-0 flex-1 truncate text-left group-data-[collapsed=true]/sidebar:
|
|
58
|
+
itemLabel: 'min-w-0 flex-1 truncate text-left group-data-[collapsed=true]/sidebar:sr-only',
|
|
59
59
|
itemBadge:
|
|
60
60
|
'ml-auto font-mono text-caption text-faint-foreground tabular-nums group-data-[collapsed=true]/sidebar:hidden',
|
|
61
61
|
footer: [
|
|
@@ -5,13 +5,42 @@ import { Container, type ContainerProps } from '../../components/container'
|
|
|
5
5
|
import { useComponentConfig } from '../../hooks/use-component-config'
|
|
6
6
|
import { useScrolled } from '../../hooks/use-scrolled'
|
|
7
7
|
import { MenuIcon, XIcon } from '../../internal/icons'
|
|
8
|
-
import { useLinkComponent } from '../../provider/glyph-context'
|
|
8
|
+
import { useIsActive, useLinkComponent } from '../../provider/glyph-context'
|
|
9
9
|
import { navbarStyles } from './app-shell.styles'
|
|
10
10
|
|
|
11
11
|
export interface NavLink {
|
|
12
12
|
label: ReactNode
|
|
13
13
|
href: string
|
|
14
|
+
/** Marks the current page. Computed from the provider's `router` when not set. */
|
|
14
15
|
active?: boolean
|
|
16
|
+
/** With automatic matching, only the exact path is active (not its children). */
|
|
17
|
+
end?: boolean
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function NavbarLink({
|
|
21
|
+
link,
|
|
22
|
+
className,
|
|
23
|
+
onClick,
|
|
24
|
+
}: {
|
|
25
|
+
link: NavLink
|
|
26
|
+
className: string
|
|
27
|
+
onClick?: () => void
|
|
28
|
+
}) {
|
|
29
|
+
const Link = useLinkComponent()
|
|
30
|
+
const active = useIsActive(link.href, {
|
|
31
|
+
explicit: link.active,
|
|
32
|
+
...(link.end !== undefined && { end: link.end }),
|
|
33
|
+
})
|
|
34
|
+
return (
|
|
35
|
+
<Link
|
|
36
|
+
href={link.href}
|
|
37
|
+
aria-current={active ? 'page' : undefined}
|
|
38
|
+
className={className}
|
|
39
|
+
{...(onClick && { onClick })}
|
|
40
|
+
>
|
|
41
|
+
{link.label}
|
|
42
|
+
</Link>
|
|
43
|
+
)
|
|
15
44
|
}
|
|
16
45
|
|
|
17
46
|
export interface NavbarProps extends Omit<ComponentProps<'header'>, 'children'> {
|
|
@@ -44,7 +73,6 @@ export function Navbar(inProps: NavbarProps) {
|
|
|
44
73
|
} = props
|
|
45
74
|
const [open, setOpen] = useState(false)
|
|
46
75
|
const scrolled = useScrolled(8, blurOnScroll)
|
|
47
|
-
const Link = useLinkComponent()
|
|
48
76
|
const s = navbarStyles({ sticky, blur: blurOnScroll })
|
|
49
77
|
|
|
50
78
|
return (
|
|
@@ -59,14 +87,7 @@ export function Navbar(inProps: NavbarProps) {
|
|
|
59
87
|
{links.length > 0 && (
|
|
60
88
|
<nav aria-label="Main" className={s.links({ class: slots.links })}>
|
|
61
89
|
{links.map((link) => (
|
|
62
|
-
<
|
|
63
|
-
key={link.href}
|
|
64
|
-
href={link.href}
|
|
65
|
-
aria-current={link.active ? 'page' : undefined}
|
|
66
|
-
className={s.link({ class: slots.link })}
|
|
67
|
-
>
|
|
68
|
-
{link.label}
|
|
69
|
-
</Link>
|
|
90
|
+
<NavbarLink key={link.href} link={link} className={s.link({ class: slots.link })} />
|
|
70
91
|
))}
|
|
71
92
|
</nav>
|
|
72
93
|
)}
|
|
@@ -96,15 +117,12 @@ export function Navbar(inProps: NavbarProps) {
|
|
|
96
117
|
</div>
|
|
97
118
|
<nav aria-label="Main" className={s.panelLinks({ class: slots.panelLinks })}>
|
|
98
119
|
{links.map((link) => (
|
|
99
|
-
<
|
|
120
|
+
<NavbarLink
|
|
100
121
|
key={link.href}
|
|
101
|
-
|
|
102
|
-
aria-current={link.active ? 'page' : undefined}
|
|
122
|
+
link={link}
|
|
103
123
|
className={s.panelLink({ class: slots.panelLink })}
|
|
104
124
|
onClick={() => setOpen(false)}
|
|
105
|
-
|
|
106
|
-
{link.label}
|
|
107
|
-
</Link>
|
|
125
|
+
/>
|
|
108
126
|
))}
|
|
109
127
|
</nav>
|
|
110
128
|
{actions && (
|
|
@@ -4,7 +4,7 @@ import { Tooltip } from '../../components/tooltip'
|
|
|
4
4
|
import { useComponentConfig } from '../../hooks/use-component-config'
|
|
5
5
|
import { useMediaQuery } from '../../hooks/use-media-query'
|
|
6
6
|
import { PanelLeftIcon } from '../../internal/icons'
|
|
7
|
-
import { useLinkComponent } from '../../provider/glyph-context'
|
|
7
|
+
import { useIsActive, useLinkComponent } from '../../provider/glyph-context'
|
|
8
8
|
import { sidebarStyles } from './app-shell.styles'
|
|
9
9
|
import { useAppShell } from './app-shell-context'
|
|
10
10
|
|
|
@@ -75,7 +75,10 @@ export interface SidebarItemProps {
|
|
|
75
75
|
icon?: ReactNode
|
|
76
76
|
/** Count or short status shown at the end. */
|
|
77
77
|
badge?: ReactNode
|
|
78
|
+
/** Marks the current page. Computed from the provider's `router` when not set. */
|
|
78
79
|
active?: boolean
|
|
80
|
+
/** With automatic matching, only the exact path is active (not its children). */
|
|
81
|
+
end?: boolean
|
|
79
82
|
onClick?: (event: MouseEvent<HTMLElement>) => void
|
|
80
83
|
className?: string
|
|
81
84
|
children: ReactNode
|
|
@@ -85,11 +88,13 @@ export function SidebarItem({
|
|
|
85
88
|
href,
|
|
86
89
|
icon,
|
|
87
90
|
badge,
|
|
88
|
-
active,
|
|
91
|
+
active: activeProp,
|
|
92
|
+
end,
|
|
89
93
|
onClick,
|
|
90
94
|
className,
|
|
91
95
|
children,
|
|
92
96
|
}: SidebarItemProps) {
|
|
97
|
+
const active = useIsActive(href, { explicit: activeProp, ...(end !== undefined && { end }) })
|
|
93
98
|
const { collapsed, inDrawer, setMobileOpen } = useAppShell()
|
|
94
99
|
const Link = useLinkComponent()
|
|
95
100
|
const slots = useSlots()
|
|
@@ -4,6 +4,11 @@ import { chartStyles } from './chart.styles'
|
|
|
4
4
|
|
|
5
5
|
/** Theme colors a series can use by name. Any CSS color also works. */
|
|
6
6
|
export type ChartColor =
|
|
7
|
+
| 'chart-1'
|
|
8
|
+
| 'chart-2'
|
|
9
|
+
| 'chart-3'
|
|
10
|
+
| 'chart-4'
|
|
11
|
+
| 'chart-5'
|
|
7
12
|
| 'primary'
|
|
8
13
|
| 'info'
|
|
9
14
|
| 'success'
|
|
@@ -12,11 +17,12 @@ export type ChartColor =
|
|
|
12
17
|
| 'foreground'
|
|
13
18
|
| 'muted-foreground'
|
|
14
19
|
|
|
15
|
-
|
|
20
|
+
/** Series without a color use the theme's chart palette, in order. */
|
|
21
|
+
const palette: ChartColor[] = ['chart-1', 'chart-2', 'chart-3', 'chart-4', 'chart-5']
|
|
16
22
|
|
|
17
23
|
export function resolveColor(color: string | undefined, index: number): string {
|
|
18
24
|
const value = color ?? palette[index % palette.length]!
|
|
19
|
-
return /^[a-z-]
|
|
25
|
+
return /^[a-z][a-z\d-]*$/.test(value) ? `var(--color-${value})` : value
|
|
20
26
|
}
|
|
21
27
|
|
|
22
28
|
export const defaultFormatter = (value: number) =>
|
|
@@ -135,6 +141,8 @@ export function ChartFrame({
|
|
|
135
141
|
<span className={s.description({ class: slots.description })}>{description}</span>
|
|
136
142
|
)}
|
|
137
143
|
</figcaption>
|
|
144
|
+
{/* Screen readers get the data table below. Charts pass `accessibilityLayer={false}`
|
|
145
|
+
so recharts adds no focusable nodes inside this hidden plot. */}
|
|
138
146
|
<div className={s.plot({ class: slots.plot })} aria-hidden="true">
|
|
139
147
|
{children}
|
|
140
148
|
</div>
|
|
@@ -135,7 +135,11 @@ export function LineChart<T extends object>(
|
|
|
135
135
|
return (
|
|
136
136
|
<ChartFrame {...frameProps(props)} legend={legend} table={table}>
|
|
137
137
|
<R.ResponsiveContainer width="100%" height={props.height ?? 240}>
|
|
138
|
-
<R.LineChart
|
|
138
|
+
<R.LineChart
|
|
139
|
+
accessibilityLayer={false}
|
|
140
|
+
data={props.data as T[]}
|
|
141
|
+
margin={{ top: 8, right: 8, bottom: 0, left: 0 }}
|
|
142
|
+
>
|
|
139
143
|
<Axes
|
|
140
144
|
index={props.index}
|
|
141
145
|
showGrid={props.showGrid}
|
|
@@ -169,7 +173,11 @@ export function AreaChart<T extends object>(props: CartesianChartProps<T> & { st
|
|
|
169
173
|
return (
|
|
170
174
|
<ChartFrame {...frameProps(props)} legend={legend} table={table}>
|
|
171
175
|
<R.ResponsiveContainer width="100%" height={props.height ?? 240}>
|
|
172
|
-
<R.AreaChart
|
|
176
|
+
<R.AreaChart
|
|
177
|
+
accessibilityLayer={false}
|
|
178
|
+
data={props.data as T[]}
|
|
179
|
+
margin={{ top: 8, right: 8, bottom: 0, left: 0 }}
|
|
180
|
+
>
|
|
173
181
|
<defs>
|
|
174
182
|
{resolved.map((item, i) => (
|
|
175
183
|
<linearGradient key={item.key} id={`${id}-${i}`} x1="0" x2="0" y1="0" y2="1">
|
|
@@ -216,7 +224,11 @@ export function BarChart<T extends object>(
|
|
|
216
224
|
return (
|
|
217
225
|
<ChartFrame {...frameProps(props)} legend={legend} table={table}>
|
|
218
226
|
<R.ResponsiveContainer width="100%" height={props.height ?? 240}>
|
|
219
|
-
<R.BarChart
|
|
227
|
+
<R.BarChart
|
|
228
|
+
accessibilityLayer={false}
|
|
229
|
+
data={props.data as T[]}
|
|
230
|
+
margin={{ top: 8, right: 8, bottom: 0, left: 0 }}
|
|
231
|
+
>
|
|
220
232
|
<Axes
|
|
221
233
|
index={props.index}
|
|
222
234
|
showGrid={props.showGrid}
|
|
@@ -286,9 +298,10 @@ export function DonutChart<T extends object>(props: DonutChartProps<T>) {
|
|
|
286
298
|
>
|
|
287
299
|
<div className="relative mx-auto" style={{ width: size, height: size }}>
|
|
288
300
|
<R.ResponsiveContainer width="100%" height="100%">
|
|
289
|
-
<R.PieChart>
|
|
301
|
+
<R.PieChart accessibilityLayer={false}>
|
|
290
302
|
{tooltip(valueFormatter, false)}
|
|
291
303
|
<R.Pie
|
|
304
|
+
rootTabIndex={-1}
|
|
292
305
|
data={rows}
|
|
293
306
|
dataKey={value}
|
|
294
307
|
nameKey={category}
|
|
@@ -346,7 +359,11 @@ export function Sparkline({
|
|
|
346
359
|
style={{ height }}
|
|
347
360
|
>
|
|
348
361
|
<R.ResponsiveContainer width="100%" height="100%">
|
|
349
|
-
<R.AreaChart
|
|
362
|
+
<R.AreaChart
|
|
363
|
+
accessibilityLayer={false}
|
|
364
|
+
data={data}
|
|
365
|
+
margin={{ top: 2, right: 2, bottom: 2, left: 2 }}
|
|
366
|
+
>
|
|
350
367
|
<defs>
|
|
351
368
|
<linearGradient id={id} x1="0" x2="0" y1="0" y2="1">
|
|
352
369
|
<stop offset="0%" stopColor={stroke} stopOpacity={area ? 0.25 : 0} />
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { standardSchemaResolver } from '@hookform/resolvers/standard-schema'
|
|
2
2
|
import type { StandardSchemaV1 } from '@standard-schema/spec'
|
|
3
|
-
import type
|
|
3
|
+
import { type ComponentProps, type MouseEvent, type ReactNode, useCallback } from 'react'
|
|
4
4
|
import {
|
|
5
5
|
type Control,
|
|
6
6
|
Controller,
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
type Resolver,
|
|
12
12
|
type UseFormProps,
|
|
13
13
|
type UseFormReturn,
|
|
14
|
+
useFormContext,
|
|
14
15
|
useFormState,
|
|
15
16
|
useForm as useHookForm,
|
|
16
17
|
} from 'react-hook-form'
|
|
@@ -62,12 +63,29 @@ export interface FormProps<TInput extends FieldValues, TOutput>
|
|
|
62
63
|
onSubmit: (values: TOutput) => SubmitResult<TInput> | Promise<SubmitResult<TInput>>
|
|
63
64
|
}
|
|
64
65
|
|
|
66
|
+
// biome-ignore lint/suspicious/noExplicitAny: keyed by any form's control object
|
|
67
|
+
type AnyControl = Control<any, any, any>
|
|
68
|
+
|
|
69
|
+
/** The rendered `<form>` of each form, so a FormSubmit outside it can submit it. */
|
|
70
|
+
const formElements = new WeakMap<AnyControl, HTMLFormElement>()
|
|
71
|
+
|
|
65
72
|
export function Form<TInput extends FieldValues, TOutput>({
|
|
66
73
|
form,
|
|
67
74
|
onSubmit,
|
|
68
75
|
children,
|
|
76
|
+
ref,
|
|
69
77
|
...props
|
|
70
78
|
}: FormProps<TInput, TOutput>) {
|
|
79
|
+
const control = form.control as AnyControl
|
|
80
|
+
const registerElement = useCallback(
|
|
81
|
+
(element: HTMLFormElement | null) => {
|
|
82
|
+
if (element) formElements.set(control, element)
|
|
83
|
+
else formElements.delete(control)
|
|
84
|
+
if (typeof ref === 'function') ref(element)
|
|
85
|
+
else if (ref) ref.current = element
|
|
86
|
+
},
|
|
87
|
+
[control, ref],
|
|
88
|
+
)
|
|
71
89
|
const submit = form.handleSubmit(async (values) => {
|
|
72
90
|
try {
|
|
73
91
|
const result = await onSubmit(values)
|
|
@@ -85,7 +103,7 @@ export function Form<TInput extends FieldValues, TOutput>({
|
|
|
85
103
|
|
|
86
104
|
return (
|
|
87
105
|
<FormProvider {...form}>
|
|
88
|
-
<form noValidate onSubmit={submit} {...props}>
|
|
106
|
+
<form noValidate onSubmit={submit} ref={registerElement} {...props}>
|
|
89
107
|
{children}
|
|
90
108
|
</form>
|
|
91
109
|
</FormProvider>
|
|
@@ -122,15 +140,39 @@ export function FormField<TInput extends FieldValues, TName extends FieldPath<TI
|
|
|
122
140
|
)
|
|
123
141
|
}
|
|
124
142
|
|
|
143
|
+
export interface FormSubmitProps extends Omit<ButtonProps, 'type' | 'form'> {
|
|
144
|
+
/**
|
|
145
|
+
* The form to submit, for a button outside `<Form>` such as a Modal footer:
|
|
146
|
+
* `<FormSubmit form={form}>`. Not needed inside `<Form>`.
|
|
147
|
+
*/
|
|
148
|
+
// biome-ignore lint/suspicious/noExplicitAny: accepts any form regardless of its value types
|
|
149
|
+
form?: UseFormReturn<any, any, any>
|
|
150
|
+
}
|
|
151
|
+
|
|
125
152
|
/** Submit button that shows progress while the form submits. */
|
|
126
|
-
export function FormSubmit(props:
|
|
127
|
-
const
|
|
128
|
-
|
|
153
|
+
export function FormSubmit({ form, onClick, ...props }: FormSubmitProps) {
|
|
154
|
+
const context = useFormContext()
|
|
155
|
+
const control = (form ?? context)?.control
|
|
156
|
+
if (!control) {
|
|
157
|
+
throw new Error(
|
|
158
|
+
'FormSubmit must be inside <Form> or receive form={form}, e.g. <FormSubmit form={form}> in a Modal footer.',
|
|
159
|
+
)
|
|
160
|
+
}
|
|
161
|
+
const { isSubmitting } = useFormState({ control })
|
|
162
|
+
|
|
163
|
+
if (!form) return <Button type="submit" loading={isSubmitting} onClick={onClick} {...props} />
|
|
164
|
+
|
|
165
|
+
// Outside <Form>, submit its element explicitly so validation and onSubmit run as usual.
|
|
166
|
+
const submitOutside = (event: MouseEvent<HTMLButtonElement>) => {
|
|
167
|
+
onClick?.(event)
|
|
168
|
+
if (!event.defaultPrevented) formElements.get(control)?.requestSubmit()
|
|
169
|
+
}
|
|
170
|
+
return <Button type="button" loading={isSubmitting} onClick={submitOutside} {...props} />
|
|
129
171
|
}
|
|
130
172
|
|
|
131
173
|
export const formErrorStyles = tv({
|
|
132
174
|
slots: {
|
|
133
|
-
base: 'flex items-start gap-2 rounded-md border border-danger/30 bg-danger/8 px-3 py-2.5 text-small text-danger',
|
|
175
|
+
base: 'flex items-start gap-2 rounded-md border border-danger/30 bg-danger/8 px-3 py-2.5 text-small text-danger-text',
|
|
134
176
|
icon: 'mt-0.5 size-4 shrink-0',
|
|
135
177
|
},
|
|
136
178
|
})
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { ColorMode } from '@glyphui/tokens'
|
|
2
|
+
|
|
3
|
+
// React-free on purpose: the Vite plugin imports this module in Node.
|
|
4
|
+
|
|
5
|
+
export type ColorModePreference = ColorMode | 'system'
|
|
6
|
+
|
|
7
|
+
export interface ColorModeOptions {
|
|
8
|
+
/** Used when nothing is stored yet. */
|
|
9
|
+
defaultMode?: ColorModePreference
|
|
10
|
+
/** localStorage key. Set to `null` to disable persistence. */
|
|
11
|
+
storageKey?: string | null
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const DARK_QUERY = '(prefers-color-scheme: dark)'
|
|
15
|
+
export const DEFAULT_STORAGE_KEY = 'glyph-mode'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A tiny inline script that sets `data-mode` on `<html>` before first paint, so dark-mode
|
|
19
|
+
* visitors never see a light flash. Use the same options as `GlyphProvider`.
|
|
20
|
+
*/
|
|
21
|
+
export function getColorModeScript({
|
|
22
|
+
defaultMode = 'system',
|
|
23
|
+
storageKey = DEFAULT_STORAGE_KEY,
|
|
24
|
+
}: ColorModeOptions = {}): string {
|
|
25
|
+
return `(function(){try{var d=${JSON.stringify(defaultMode)},m=null;${
|
|
26
|
+
storageKey ? `m=localStorage.getItem(${JSON.stringify(storageKey)});` : ''
|
|
27
|
+
}if(m!=='light'&&m!=='dark'&&m!=='system')m=d;if(m==='system')m=matchMedia('${DARK_QUERY}').matches?'dark':'light';var r=document.documentElement;r.dataset.mode=m;r.style.colorScheme=m}catch(e){}})()`
|
|
28
|
+
}
|