@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
@@ -14,6 +14,18 @@ function useGlyph() {
14
14
  function useLinkComponent() {
15
15
  return useContext(GlyphContext)?.linkComponent ?? "a";
16
16
  }
17
+ const neverActive = () => false;
18
+ /**
19
+ * Whether `href` is the current page, using the router passed to `GlyphProvider`.
20
+ * Returns `explicit` when it is set, so an `active` prop always wins.
21
+ */
22
+ function useIsActive(href, options) {
23
+ const isActive = useContext(GlyphContext)?.useIsActive ?? neverActive;
24
+ const { explicit, ...matchOptions } = options ?? {};
25
+ const matched = isActive(href ?? "", matchOptions);
26
+ if (explicit !== void 0) return explicit;
27
+ return href ? matched : false;
28
+ }
17
29
  //#endregion
18
30
  //#region src/hooks/use-component-config.ts
19
31
  /**
@@ -55,4 +67,4 @@ const twMergeConfig = { extend: { theme: {
55
67
  /** `tailwind-variants` preconfigured with Glyph's merge rules. All component styles use this. */
56
68
  const tv = createTV({ twMergeConfig });
57
69
  //#endregion
58
- export { themedBorder as a, GlyphContext as c, popover as i, useGlyph as l, twMergeConfig as n, transition as o, focusRing as r, useComponentConfig as s, tv as t, useLinkComponent as u };
70
+ export { themedBorder as a, GlyphContext as c, useIsActive as d, useLinkComponent as f, popover as i, neverActive as l, twMergeConfig as n, transition as o, focusRing as r, useComponentConfig as s, tv as t, useGlyph as u };
package/dist/vite.d.ts ADDED
@@ -0,0 +1,27 @@
1
+ import { t as ColorModeOptions } from "./color-mode-script-KdwItKN2.js";
2
+ //#region src/integrations/vite.d.ts
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: () => {
9
+ tag: 'script';
10
+ children: string;
11
+ injectTo: 'head-prepend';
12
+ }[];
13
+ };
14
+ }
15
+ /**
16
+ * Injects Glyph's color-mode script into `index.html` so dark-mode visitors never see a
17
+ * light flash. Pass the same `defaultMode` and `storageKey` as `GlyphProvider`.
18
+ *
19
+ * ```ts
20
+ * // vite.config.ts
21
+ * import { glyphColorMode } from '@glyphui/ui/vite'
22
+ * export default defineConfig({ plugins: [react(), tailwindcss(), glyphColorMode()] })
23
+ * ```
24
+ */
25
+ export declare function glyphColorMode(options?: ColorModeOptions): GlyphVitePlugin;
26
+ //#endregion
27
+ export type { ColorModeOptions };
package/dist/vite.js ADDED
@@ -0,0 +1,29 @@
1
+ "use client";
2
+ import { r as getColorModeScript } from "./color-mode-script-Bf7LYkOj.js";
3
+ //#region src/integrations/vite.ts
4
+ /**
5
+ * Injects Glyph's color-mode script into `index.html` so dark-mode visitors never see a
6
+ * light flash. Pass the same `defaultMode` and `storageKey` as `GlyphProvider`.
7
+ *
8
+ * ```ts
9
+ * // vite.config.ts
10
+ * import { glyphColorMode } from '@glyphui/ui/vite'
11
+ * export default defineConfig({ plugins: [react(), tailwindcss(), glyphColorMode()] })
12
+ * ```
13
+ */
14
+ function glyphColorMode(options = {}) {
15
+ const script = getColorModeScript(options);
16
+ return {
17
+ name: "glyph:color-mode",
18
+ transformIndexHtml: {
19
+ order: "pre",
20
+ handler: () => [{
21
+ tag: "script",
22
+ children: script,
23
+ injectTo: "head-prepend"
24
+ }]
25
+ }
26
+ };
27
+ }
28
+ //#endregion
29
+ export { glyphColorMode };
package/fonts.css ADDED
@@ -0,0 +1,10 @@
1
+ /*
2
+ * Geist and Geist Mono, the fonts the default `glyph` theme uses. Import once, before the
3
+ * Glyph stylesheet:
4
+ *
5
+ * @import "@glyphui/ui/fonts.css";
6
+ * @import "tailwindcss";
7
+ * @import "@glyphui/ui/styles.css";
8
+ */
9
+ @import "@fontsource-variable/geist";
10
+ @import "@fontsource-variable/geist-mono";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glyphui/ui",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "React components on Tailwind CSS v4 that take their entire look from one theme file. Forms, data tables, app layouts and async states included.",
5
5
  "license": "MIT",
6
6
  "author": "Mohak Gupta (https://github.com/khalmohak)",
@@ -24,7 +24,8 @@
24
24
  "theme",
25
25
  "dark-mode",
26
26
  "forms",
27
- "data-table"
27
+ "data-table",
28
+ "react-router"
28
29
  ],
29
30
  "type": "module",
30
31
  "sideEffects": [
@@ -51,19 +52,33 @@
51
52
  "types": "./dist/chart.d.ts",
52
53
  "default": "./dist/chart.js"
53
54
  },
55
+ "./react-router": {
56
+ "@glyphui/source": "./src/integrations/react-router.tsx",
57
+ "types": "./dist/react-router.d.ts",
58
+ "default": "./dist/react-router.js"
59
+ },
60
+ "./vite": {
61
+ "@glyphui/source": "./src/integrations/vite.ts",
62
+ "types": "./dist/vite.d.ts",
63
+ "default": "./dist/vite.js"
64
+ },
54
65
  "./styles.css": "./styles.css",
66
+ "./fonts.css": "./fonts.css",
55
67
  "./package.json": "./package.json"
56
68
  },
57
69
  "files": [
58
70
  "dist",
59
71
  "src",
60
72
  "styles.css",
73
+ "fonts.css",
61
74
  "!src/**/*.test.tsx",
62
75
  "!src/**/*.test-d.tsx",
63
76
  "!src/test"
64
77
  ],
65
78
  "dependencies": {
66
- "@glyphui/tokens": "^0.3.0",
79
+ "@fontsource-variable/geist": "^5.3.0",
80
+ "@fontsource-variable/geist-mono": "^5.3.0",
81
+ "@glyphui/tokens": "^0.4.0",
67
82
  "@standard-schema/spec": "^1.1.0",
68
83
  "clsx": "^2.1.1",
69
84
  "cmdk": "^1.1.1",
@@ -83,8 +98,10 @@
83
98
  "react": ">=19",
84
99
  "react-dom": ">=19",
85
100
  "react-hook-form": "^7.55.0",
101
+ "react-router": ">=7",
86
102
  "recharts": "^3.0.0",
87
- "tailwindcss": ">=4.1"
103
+ "tailwindcss": ">=4.1",
104
+ "vite": ">=5"
88
105
  },
89
106
  "peerDependenciesMeta": {
90
107
  "@hookform/resolvers": {
@@ -96,8 +113,14 @@
96
113
  "react-hook-form": {
97
114
  "optional": true
98
115
  },
116
+ "react-router": {
117
+ "optional": true
118
+ },
99
119
  "recharts": {
100
120
  "optional": true
121
+ },
122
+ "vite": {
123
+ "optional": true
101
124
  }
102
125
  },
103
126
  "devDependencies": {
@@ -110,13 +133,16 @@
110
133
  "@testing-library/user-event": "^14.6.7",
111
134
  "@types/react": "^19.2.18",
112
135
  "@types/react-dom": "^19.2.7",
136
+ "@vitest/coverage-v8": "3.2.7",
113
137
  "jsdom": "^27.4.0",
114
138
  "react": "^19.2.8",
115
139
  "react-dom": "^19.2.8",
116
140
  "react-hook-form": "^7.89.0",
141
+ "react-router": "^7.18.4",
117
142
  "recharts": "^3.10.1",
118
143
  "tsdown": "^0.23.0",
119
144
  "typescript": "~5.9.3",
145
+ "vite": "^7.3.6",
120
146
  "vitest": "^3.2.7",
121
147
  "zod": "^4.6.5"
122
148
  },
@@ -128,6 +154,7 @@
128
154
  "build": "tsdown",
129
155
  "typecheck": "tsc",
130
156
  "test": "vitest run",
131
- "check:exports": "attw --pack . --profile esm-only --exclude-entrypoints ./styles.css"
157
+ "test:coverage": "vitest run --coverage",
158
+ "check:exports": "attw --pack . --profile esm-only --exclude-entrypoints ./styles.css ./fonts.css"
132
159
  }
133
160
  }
@@ -1,7 +1,7 @@
1
1
  import { themedBorder } from '../../utils/styles'
2
2
  import { tv, type VariantProps } from '../../utils/tv'
3
3
 
4
- // Tinted text is mixed toward the foreground so it stays readable on light and dark surfaces.
4
+ // Tinted text uses the theme's status text colors, which stay readable on tinted backgrounds.
5
5
  const tones = {
6
6
  neutral: {
7
7
  soft: 'bg-muted text-muted-foreground',
@@ -9,34 +9,29 @@ const tones = {
9
9
  outline: 'border-border-strong text-foreground',
10
10
  },
11
11
  primary: {
12
- soft: 'bg-primary/14 text-[color-mix(in_oklab,var(--color-primary)_62%,var(--color-foreground))]',
12
+ soft: 'bg-primary/14 text-primary-text',
13
13
  solid: 'bg-primary text-primary-foreground',
14
- outline:
15
- 'border-primary/50 text-[color-mix(in_oklab,var(--color-primary)_62%,var(--color-foreground))]',
14
+ outline: 'border-primary/50 text-primary-text',
16
15
  },
17
16
  success: {
18
- soft: 'bg-success/14 text-[color-mix(in_oklab,var(--color-success)_62%,var(--color-foreground))]',
17
+ soft: 'bg-success/14 text-success-text',
19
18
  solid: 'bg-success text-success-foreground',
20
- outline:
21
- 'border-success/50 text-[color-mix(in_oklab,var(--color-success)_62%,var(--color-foreground))]',
19
+ outline: 'border-success/50 text-success-text',
22
20
  },
23
21
  warning: {
24
- soft: 'bg-warning/16 text-[color-mix(in_oklab,var(--color-warning)_62%,var(--color-foreground))]',
22
+ soft: 'bg-warning/14 text-warning-text',
25
23
  solid: 'bg-warning text-warning-foreground',
26
- outline:
27
- 'border-warning/50 text-[color-mix(in_oklab,var(--color-warning)_62%,var(--color-foreground))]',
24
+ outline: 'border-warning/50 text-warning-text',
28
25
  },
29
26
  danger: {
30
- soft: 'bg-danger/14 text-[color-mix(in_oklab,var(--color-danger)_62%,var(--color-foreground))]',
27
+ soft: 'bg-danger/14 text-danger-text',
31
28
  solid: 'bg-danger text-danger-foreground',
32
- outline:
33
- 'border-danger/50 text-[color-mix(in_oklab,var(--color-danger)_62%,var(--color-foreground))]',
29
+ outline: 'border-danger/50 text-danger-text',
34
30
  },
35
31
  info: {
36
- soft: 'bg-info/14 text-[color-mix(in_oklab,var(--color-info)_62%,var(--color-foreground))]',
32
+ soft: 'bg-info/14 text-info-text',
37
33
  solid: 'bg-info text-info-foreground',
38
- outline:
39
- 'border-info/50 text-[color-mix(in_oklab,var(--color-info)_62%,var(--color-foreground))]',
34
+ outline: 'border-info/50 text-info-text',
40
35
  },
41
36
  } as const
42
37
 
@@ -15,16 +15,16 @@ export const bannerStyles = tv({
15
15
  neutral: { root: 'border-border bg-subtle text-foreground', icon: 'text-muted-foreground' },
16
16
  primary: { root: 'border-primary bg-primary text-primary-foreground' },
17
17
  info: {
18
- root: 'border-info/25 bg-info/10 text-[color-mix(in_oklab,var(--color-info)_55%,var(--color-foreground))]',
18
+ root: 'border-info/25 bg-info/10 text-info-text',
19
19
  },
20
20
  success: {
21
- root: 'border-success/25 bg-success/10 text-[color-mix(in_oklab,var(--color-success)_55%,var(--color-foreground))]',
21
+ root: 'border-success/25 bg-success/10 text-success-text',
22
22
  },
23
23
  warning: {
24
- root: 'border-warning/30 bg-warning/14 text-[color-mix(in_oklab,var(--color-warning)_50%,var(--color-foreground))]',
24
+ root: 'border-warning/30 bg-warning/14 text-warning-text',
25
25
  },
26
26
  danger: {
27
- root: 'border-danger/25 bg-danger/10 text-[color-mix(in_oklab,var(--color-danger)_55%,var(--color-foreground))]',
27
+ root: 'border-danger/25 bg-danger/10 text-danger-text',
28
28
  },
29
29
  },
30
30
  },
@@ -22,7 +22,9 @@ export const buttonStyles = tv({
22
22
  },
23
23
  ghost: { base: 'text-muted-foreground hover:bg-muted hover:text-foreground' },
24
24
  danger: { base: 'bg-danger text-danger-foreground shadow-sm hover:bg-danger/88' },
25
- link: { base: 'h-auto px-0 text-primary underline-offset-4 shadow-none hover:underline' },
25
+ link: {
26
+ base: 'h-auto px-0 text-primary-text underline-offset-4 shadow-none hover:underline',
27
+ },
26
28
  },
27
29
  size: {
28
30
  sm: { base: 'h-(--glyph-control-sm) px-3 text-small', icon: 'size-3.5' },
@@ -29,10 +29,11 @@ export const calendarStyles = tv({
29
29
  transition,
30
30
  focusRing,
31
31
  ],
32
- today: '[&>button]:font-semibold [&>button]:text-primary',
32
+ // Selected days keep primary-foreground on primary, whatever else applies to them.
33
+ today: '[&>button]:font-semibold [&:not([data-selected])>button]:text-primary-text',
33
34
  selected:
34
35
  '[&>button]:bg-primary [&>button]:text-primary-foreground [&>button]:hover:bg-primary/90',
35
- outside: '[&>button]:text-faint-foreground [&>button]:opacity-60',
36
+ outside: '[&:not([data-selected])>button]:text-faint-foreground',
36
37
  disabled: '[&>button]:pointer-events-none [&>button]:opacity-35 [&>button]:line-through',
37
38
  rangeStart: 'rounded-l-md bg-primary/14',
38
39
  rangeMiddle:
@@ -27,8 +27,8 @@ export const dropdownMenuStyles = tv({
27
27
  tone: {
28
28
  default: {},
29
29
  danger: {
30
- item: 'text-danger data-highlighted:bg-danger/10',
31
- icon: 'text-danger',
30
+ item: 'text-danger-text data-highlighted:bg-danger/10',
31
+ icon: 'text-danger-text',
32
32
  },
33
33
  },
34
34
  inset: { true: { item: 'pl-8', label: 'pl-8' } },
@@ -5,7 +5,7 @@ export const eyebrowStyles = tv({
5
5
  variants: {
6
6
  tone: {
7
7
  muted: { base: 'text-muted-foreground' },
8
- primary: { base: 'text-primary' },
8
+ primary: { base: 'text-primary-text' },
9
9
  },
10
10
  },
11
11
  defaultVariants: { tone: 'muted' },
@@ -2,6 +2,8 @@ import { createContext, useContext } from 'react'
2
2
 
3
3
  export interface FieldContextValue {
4
4
  id: string
5
+ /** Id of the visible label, for controls labelled by reference (aria-labelledby). */
6
+ labelId: string | undefined
5
7
  descriptionId: string | undefined
6
8
  errorId: string | undefined
7
9
  invalid: boolean
@@ -4,10 +4,12 @@ export const fieldStyles = tv({
4
4
  slots: {
5
5
  root: 'grid gap-1.5',
6
6
  label: '',
7
- required: 'text-danger',
7
+ labelRow: 'flex items-center justify-between gap-2',
8
+ labelAction: 'text-small',
9
+ required: 'text-danger-text',
8
10
  text: 'grid gap-0.5',
9
11
  description: 'text-small text-faint-foreground',
10
- error: 'text-small font-medium text-danger',
12
+ error: 'text-small font-medium text-danger-text',
11
13
  },
12
14
  variants: {
13
15
  orientation: {
@@ -6,6 +6,11 @@ import { FieldContext } from './field-context'
6
6
 
7
7
  export interface FieldProps extends Omit<ComponentProps<'div'>, 'children'>, FieldVariants {
8
8
  label?: ReactNode
9
+ /**
10
+ * Shown at the end of the label row, outside the label, so it is not part of the
11
+ * control's accessible name. For example a "Forgot password?" link.
12
+ */
13
+ labelAction?: ReactNode
9
14
  description?: ReactNode
10
15
  /** Error message. Marks the control invalid and is announced to screen readers. */
11
16
  error?: ReactNode
@@ -27,6 +32,7 @@ export function Field(inProps: FieldProps) {
27
32
  const [props, slots] = useComponentConfig('Field', inProps)
28
33
  const {
29
34
  label,
35
+ labelAction,
30
36
  description,
31
37
  error,
32
38
  required = false,
@@ -40,17 +46,18 @@ export function Field(inProps: FieldProps) {
40
46
  const generated = useId()
41
47
  const id = controlId ?? generated
42
48
  const invalid = Boolean(error)
49
+ const labelId = label ? `${id}-label` : undefined
43
50
  const descriptionId = description ? `${id}-description` : undefined
44
51
  const errorId = invalid ? `${id}-error` : undefined
45
52
  const s = fieldStyles({ orientation })
46
53
 
47
54
  const context = useMemo(
48
- () => ({ id, descriptionId, errorId, invalid, required, disabled }),
49
- [id, descriptionId, errorId, invalid, required, disabled],
55
+ () => ({ id, labelId, descriptionId, errorId, invalid, required, disabled }),
56
+ [id, labelId, descriptionId, errorId, invalid, required, disabled],
50
57
  )
51
58
 
52
- const labelNode = label ? (
53
- <Label htmlFor={id} className={s.label({ class: slots.label })}>
59
+ const labelElement = label ? (
60
+ <Label id={labelId} htmlFor={id} className={s.label({ class: slots.label })}>
54
61
  {label}
55
62
  {required && (
56
63
  <span className={s.required({ class: slots.required })} aria-hidden="true">
@@ -59,6 +66,20 @@ export function Field(inProps: FieldProps) {
59
66
  )}
60
67
  </Label>
61
68
  ) : null
69
+ const labelNode =
70
+ labelElement && labelAction ? (
71
+ <div data-slot="field-label-row" className={s.labelRow({ class: slots.labelRow })}>
72
+ {labelElement}
73
+ <span
74
+ data-slot="field-label-action"
75
+ className={s.labelAction({ class: slots.labelAction })}
76
+ >
77
+ {labelAction}
78
+ </span>
79
+ </div>
80
+ ) : (
81
+ labelElement
82
+ )
62
83
 
63
84
  const messages = (
64
85
  <>
@@ -16,9 +16,9 @@ export const fileUploadStyles = tv({
16
16
  ],
17
17
  icon: 'flex size-10 items-center justify-center rounded-md border border-border bg-surface text-muted-foreground shadow-xs [&>svg]:size-5',
18
18
  title: 'text-small font-medium text-foreground',
19
- highlight: 'text-primary',
19
+ highlight: 'text-primary-text',
20
20
  hint: 'text-caption text-faint-foreground',
21
- rejections: 'grid gap-1 text-small font-medium text-danger',
21
+ rejections: 'grid gap-1 text-small font-medium text-danger-text',
22
22
  list: 'grid gap-2',
23
23
  item: [
24
24
  'grid grid-cols-[auto_1fr_auto] items-center gap-3 rounded-md border-border bg-surface px-3 py-2.5',
@@ -29,7 +29,7 @@ export const fileUploadStyles = tv({
29
29
  fileText: 'grid min-w-0 gap-1',
30
30
  fileName: 'truncate text-small font-medium text-foreground',
31
31
  fileMeta: 'text-caption tabular-nums text-faint-foreground',
32
- fileError: 'text-caption font-medium text-danger',
32
+ fileError: 'text-caption font-medium text-danger-text',
33
33
  progress: 'h-1 overflow-hidden rounded-pill bg-muted',
34
34
  progressBar:
35
35
  'h-full rounded-pill bg-primary transition-[width] duration-(--glyph-duration-base)',
@@ -17,7 +17,7 @@ export const linkStyles = tv({
17
17
  tone: {
18
18
  default: { base: 'text-foreground' },
19
19
  muted: { base: 'text-muted-foreground hover:text-foreground' },
20
- primary: { base: 'text-primary' },
20
+ primary: { base: 'text-primary-text' },
21
21
  },
22
22
  underline: {
23
23
  hover: {
@@ -8,6 +8,8 @@ export const modalStyles = tv({
8
8
  header: '',
9
9
  body: 'grid gap-4',
10
10
  footer: '',
11
+ /** Wraps body and footer when `onSubmit` is set; `contents` keeps the dialog layout. */
12
+ form: 'contents',
11
13
  },
12
14
  })
13
15
 
@@ -1,4 +1,4 @@
1
- import type { ReactNode } from 'react'
1
+ import type { FormEvent, ReactNode } from 'react'
2
2
  import { useComponentConfig } from '../../hooks/use-component-config'
3
3
  import {
4
4
  Dialog,
@@ -14,7 +14,7 @@ import {
14
14
  } from '../dialog'
15
15
  import { modalStyles } from './modal.styles'
16
16
 
17
- export interface ModalProps extends Omit<DialogContentProps, 'title' | 'children'> {
17
+ export interface ModalProps extends Omit<DialogContentProps, 'title' | 'children' | 'onSubmit'> {
18
18
  open?: boolean
19
19
  defaultOpen?: boolean
20
20
  onOpenChange?: (open: boolean) => void
@@ -27,6 +27,12 @@ export interface ModalProps extends Omit<DialogContentProps, 'title' | 'children
27
27
  footer?: ReactNode
28
28
  /** Close when the backdrop is clicked. Default true. */
29
29
  closeOnOverlayClick?: boolean
30
+ /**
31
+ * Turns the body and footer into one `<form>`, so a `type="submit"` button in the footer
32
+ * submits the fields in the body. The page does not reload. With `@glyphui/ui/form`, use
33
+ * `<Form>` in the body and `<FormSubmit form={form}>` in the footer instead.
34
+ */
35
+ onSubmit?: (event: FormEvent<HTMLFormElement>) => void
30
36
  children?: ReactNode
31
37
  }
32
38
 
@@ -45,12 +51,21 @@ export function Modal(inProps: ModalProps) {
45
51
  trigger,
46
52
  footer,
47
53
  closeOnOverlayClick = true,
54
+ onSubmit,
48
55
  onPointerDownOutside,
49
56
  className,
50
57
  children,
51
58
  ...rest
52
59
  } = props
53
60
  const s = modalStyles()
61
+ const content = (
62
+ <>
63
+ {children && <DialogBody className={s.body({ class: slots.body })}>{children}</DialogBody>}
64
+ {footer && (
65
+ <DialogFooter className={s.footer({ class: slots.footer })}>{footer}</DialogFooter>
66
+ )}
67
+ </>
68
+ )
54
69
 
55
70
  return (
56
71
  <Dialog
@@ -73,9 +88,19 @@ export function Modal(inProps: ModalProps) {
73
88
  <DialogTitle>{title}</DialogTitle>
74
89
  {description && <DialogDescription>{description}</DialogDescription>}
75
90
  </DialogHeader>
76
- {children && <DialogBody className={s.body({ class: slots.body })}>{children}</DialogBody>}
77
- {footer && (
78
- <DialogFooter className={s.footer({ class: slots.footer })}>{footer}</DialogFooter>
91
+ {onSubmit ? (
92
+ <form
93
+ data-slot="modal-form"
94
+ className={s.form({ class: slots.form })}
95
+ onSubmit={(event) => {
96
+ event.preventDefault()
97
+ onSubmit(event)
98
+ }}
99
+ >
100
+ {content}
101
+ </form>
102
+ ) : (
103
+ content
79
104
  )}
80
105
  </DialogContent>
81
106
  </Dialog>
@@ -1,7 +1,7 @@
1
1
  import { NavigationMenu as NavPrimitive } from 'radix-ui'
2
2
  import type { ComponentProps, ReactNode } from 'react'
3
3
  import { useComponentConfig } from '../../hooks/use-component-config'
4
- import { useLinkComponent } from '../../provider/glyph-context'
4
+ import { useIsActive, useLinkComponent } from '../../provider/glyph-context'
5
5
  import { navigationMenuStyles } from './navigation-menu.styles'
6
6
 
7
7
  const s = navigationMenuStyles()
@@ -132,25 +132,35 @@ export interface NavigationMenuLinkProps
132
132
  href?: string
133
133
  }
134
134
 
135
- /** A top-level link (no panel). Set `active` for the current page. */
135
+ /**
136
+ * A top-level link (no panel). Marked active automatically from the provider's `router`;
137
+ * set `active` to override.
138
+ */
136
139
  export function NavigationMenuLink({
137
140
  href,
141
+ active: activeProp,
138
142
  className,
139
143
  children,
140
144
  ...props
141
145
  }: NavigationMenuLinkProps) {
142
146
  const slots = useSlots()
143
147
  const Link = useLinkComponent()
148
+ const active = useIsActive(href, { explicit: activeProp })
144
149
  const cls = s.link({ class: [slots.link, className] })
145
150
  if (href === undefined) {
146
151
  return (
147
- <NavPrimitive.Link data-slot="navigation-menu-link" className={cls} {...props}>
152
+ <NavPrimitive.Link
153
+ data-slot="navigation-menu-link"
154
+ className={cls}
155
+ active={active}
156
+ {...props}
157
+ >
148
158
  {children}
149
159
  </NavPrimitive.Link>
150
160
  )
151
161
  }
152
162
  return (
153
- <NavPrimitive.Link data-slot="navigation-menu-link" asChild {...props}>
163
+ <NavPrimitive.Link data-slot="navigation-menu-link" asChild active={active} {...props}>
154
164
  <Link href={href} className={cls}>
155
165
  {children}
156
166
  </Link>
@@ -16,9 +16,9 @@ export const passwordInputStyles = tv({
16
16
  },
17
17
  variants: {
18
18
  strength: {
19
- weak: { label: 'text-danger' },
20
- fair: { label: 'text-warning' },
21
- strong: { label: 'text-success' },
19
+ weak: { label: 'text-danger-text' },
20
+ fair: { label: 'text-warning-text' },
21
+ strong: { label: 'text-success-text' },
22
22
  },
23
23
  },
24
24
  })
@@ -20,7 +20,7 @@ export const proseStyles = tv({
20
20
  // Text
21
21
  '[&_p]:my-[1em] [&_p]:text-pretty',
22
22
  '[&_strong]:font-semibold [&_strong]:text-foreground',
23
- '[&_a]:font-medium [&_a]:text-primary [&_a]:underline [&_a]:decoration-primary/40 [&_a]:underline-offset-4 [&_a:hover]:decoration-primary',
23
+ '[&_a]:font-medium [&_a]:text-primary-text [&_a]:underline [&_a]:decoration-primary/40 [&_a]:underline-offset-4 [&_a:hover]:decoration-primary',
24
24
  // Lists
25
25
  '[&_ul]:my-[1em] [&_ul]:list-disc [&_ul]:pl-[1.5em] [&_ol]:my-[1em] [&_ol]:list-decimal [&_ol]:pl-[1.5em]',
26
26
  '[&_li]:my-[0.35em] [&_li]:pl-[0.25em] [&_li::marker]:text-faint-foreground',
@@ -1,5 +1,5 @@
1
1
  import { ScrollArea as ScrollAreaPrimitive } from 'radix-ui'
2
- import type { ComponentProps } from 'react'
2
+ import { type ComponentProps, useEffect, useRef, useState } from 'react'
3
3
  import { useComponentConfig } from '../../hooks/use-component-config'
4
4
  import type { StylesEntry } from '../../utils/registry'
5
5
  import { focusRing } from '../../utils/styles'
@@ -31,6 +31,24 @@ export interface ScrollAreaProps extends ComponentProps<typeof ScrollAreaPrimiti
31
31
  viewportClassName?: string
32
32
  }
33
33
 
34
+ /** True while the element's content overflows it, so keyboard users need to reach it. */
35
+ function useOverflowing<E extends HTMLElement>() {
36
+ const ref = useRef<E>(null)
37
+ const [overflowing, setOverflowing] = useState(false)
38
+ useEffect(() => {
39
+ const el = ref.current
40
+ if (!el) return
41
+ const measure = () =>
42
+ setOverflowing(el.scrollHeight > el.clientHeight || el.scrollWidth > el.clientWidth)
43
+ measure()
44
+ const observer = new ResizeObserver(measure)
45
+ observer.observe(el)
46
+ if (el.firstElementChild) observer.observe(el.firstElementChild)
47
+ return () => observer.disconnect()
48
+ }, [])
49
+ return [ref, overflowing] as const
50
+ }
51
+
34
52
  /**
35
53
  * A scrollable region with slim, theme-colored scrollbars. Give it a height (or width)
36
54
  * with `className`, e.g. `h-72`.
@@ -39,6 +57,7 @@ export function ScrollArea(inProps: ScrollAreaProps) {
39
57
  const [props, slots] = useComponentConfig('ScrollArea', inProps)
40
58
  const { orientation = 'vertical', viewportClassName, className, children, ...rest } = props
41
59
  const s = scrollAreaStyles()
60
+ const [viewportRef, overflowing] = useOverflowing<HTMLDivElement>()
42
61
  const bar = (o: 'vertical' | 'horizontal') => (
43
62
  <ScrollAreaPrimitive.Scrollbar
44
63
  data-slot="scroll-area-scrollbar"
@@ -55,7 +74,10 @@ export function ScrollArea(inProps: ScrollAreaProps) {
55
74
  {...rest}
56
75
  >
57
76
  <ScrollAreaPrimitive.Viewport
77
+ ref={viewportRef}
58
78
  data-slot="scroll-area-viewport"
79
+ // Scrollable regions must be reachable by keyboard to scroll with arrow keys.
80
+ tabIndex={overflowing ? 0 : undefined}
59
81
  className={s.viewport({ class: [slots.viewport, viewportClassName] })}
60
82
  >
61
83
  {children}