@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.
Files changed (63) hide show
  1. package/README.md +65 -12
  2. package/dist/chart.d.ts +1 -1
  3. package/dist/chart.js +28 -19
  4. package/dist/color-mode-script-Bf7LYkOj.js +13 -0
  5. package/dist/color-mode-script-KdwItKN2.d.ts +16 -0
  6. package/dist/data-table.js +3 -3
  7. package/dist/{field-CWZ_FdkD.js → field-CVbywksE.js} +21 -6
  8. package/dist/{field-context-anGPYXbW.js → field-context-7I-cWvvS.js} +2 -2
  9. package/dist/form.d.ts +11 -4
  10. package/dist/form.js +34 -9
  11. package/dist/glyph-context-DxGJrdvr.d.ts +50 -0
  12. package/dist/{index-BWK4czIG.d.ts → index-Bi7rZ6ba.d.ts} +11 -0
  13. package/dist/index.d.ts +87 -80
  14. package/dist/index.js +155 -82
  15. package/dist/react-router.d.ts +18 -0
  16. package/dist/react-router.js +44 -0
  17. package/dist/{states-DR-OoDT-.js → states-IvhjBgna.js} +2 -2
  18. package/dist/{tv-DBAGlgXJ.js → tv-DLbT66dC.js} +13 -1
  19. package/dist/vite.d.ts +27 -0
  20. package/dist/vite.js +29 -0
  21. package/fonts.css +10 -0
  22. package/package.json +32 -5
  23. package/src/components/badge/badge.styles.ts +11 -16
  24. package/src/components/banner/banner.styles.ts +4 -4
  25. package/src/components/button/button.styles.ts +3 -1
  26. package/src/components/calendar/calendar.styles.ts +3 -2
  27. package/src/components/dropdown-menu/dropdown-menu.styles.ts +2 -2
  28. package/src/components/eyebrow/eyebrow.styles.ts +1 -1
  29. package/src/components/field/field-context.ts +2 -0
  30. package/src/components/field/field.styles.ts +4 -2
  31. package/src/components/field/field.tsx +25 -4
  32. package/src/components/file-upload/file-upload.styles.ts +3 -3
  33. package/src/components/link/link.styles.ts +1 -1
  34. package/src/components/modal/modal.styles.ts +2 -0
  35. package/src/components/modal/modal.tsx +30 -5
  36. package/src/components/navigation-menu/navigation-menu.tsx +14 -4
  37. package/src/components/password-input/password-input.styles.ts +3 -3
  38. package/src/components/prose/prose.tsx +1 -1
  39. package/src/components/scroll-area/scroll-area.tsx +23 -1
  40. package/src/components/segmented-control/segmented-control.tsx +17 -2
  41. package/src/components/slider/slider.tsx +9 -2
  42. package/src/components/stepper/stepper.styles.ts +2 -2
  43. package/src/components/tag-input/tag-input.styles.ts +1 -1
  44. package/src/components/text/text.styles.ts +4 -4
  45. package/src/integrations/react-router.tsx +39 -0
  46. package/src/integrations/vite.test-d.ts +6 -0
  47. package/src/integrations/vite.test.ts +28 -0
  48. package/src/integrations/vite.ts +33 -0
  49. package/src/internal/dev.ts +12 -0
  50. package/src/patterns/app-shell/app-shell.styles.ts +1 -1
  51. package/src/patterns/app-shell/navbar.tsx +34 -16
  52. package/src/patterns/app-shell/sidebar.tsx +7 -2
  53. package/src/patterns/chart/chart-parts.tsx +10 -2
  54. package/src/patterns/chart/chart.tsx +22 -5
  55. package/src/patterns/form/form.tsx +48 -6
  56. package/src/patterns/form/index.ts +1 -0
  57. package/src/provider/color-mode-script.ts +28 -0
  58. package/src/provider/color-mode.tsx +14 -20
  59. package/src/provider/glyph-context.ts +38 -0
  60. package/src/provider/glyph-provider.tsx +16 -4
  61. package/src/provider/index.ts +4 -0
  62. package/src/provider/theme-scope.tsx +7 -3
  63. package/styles.css +72 -0
@@ -1,8 +1,10 @@
1
1
  import { ToggleGroup } from 'radix-ui'
2
- import type { ReactNode } from 'react'
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
- 'aria-label': string
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 { ReactNode } from 'react'
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,6 @@
1
+ // Type-level contract: the plugin plugs into Vite without casts. Checked by `tsc`.
2
+ import type { Plugin } from 'vite'
3
+ import { glyphColorMode } from './vite'
4
+
5
+ const plugin: Plugin = glyphColorMode({ defaultMode: 'dark' })
6
+ void plugin
@@ -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:hidden',
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
- <Link
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
- <Link
120
+ <NavbarLink
100
121
  key={link.href}
101
- href={link.href}
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
- const palette: ChartColor[] = ['primary', 'info', 'success', 'warning', 'danger']
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-]+$/.test(value) ? `var(--color-${value})` : value
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 data={props.data as T[]} margin={{ top: 8, right: 8, bottom: 0, left: 0 }}>
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 data={props.data as T[]} margin={{ top: 8, right: 8, bottom: 0, left: 0 }}>
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 data={props.data as T[]} margin={{ top: 8, right: 8, bottom: 0, left: 0 }}>
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 data={data} margin={{ top: 2, right: 2, bottom: 2, left: 2 }}>
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 { ComponentProps, ReactNode } from 'react'
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: Omit<ButtonProps, 'type'>) {
127
- const { isSubmitting } = useFormState()
128
- return <Button type="submit" loading={isSubmitting} {...props} />
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
  })
@@ -6,6 +6,7 @@ export {
6
6
  type FormFieldProps,
7
7
  type FormProps,
8
8
  FormSubmit,
9
+ type FormSubmitProps,
9
10
  formErrorStyles,
10
11
  type SubmitResult,
11
12
  type UseFormOptions,
@@ -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
+ }