@nixxie-cms/core 2.0.0 → 2.2.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 (128) hide show
  1. package/admin-ui/components/dist/nixxie-cms-core-admin-ui-components.cjs.js +7 -7
  2. package/admin-ui/components/dist/nixxie-cms-core-admin-ui-components.esm.js +7 -7
  3. package/admin-ui/context/dist/nixxie-cms-core-admin-ui-context.cjs.js +2 -2
  4. package/admin-ui/context/dist/nixxie-cms-core-admin-ui-context.esm.js +2 -2
  5. package/admin-ui/utils/dist/nixxie-cms-core-admin-ui-utils.cjs.js +5 -5
  6. package/admin-ui/utils/dist/nixxie-cms-core-admin-ui-utils.esm.js +3 -3
  7. package/dist/{CreateItemDialog-7008b050.esm.js → CreateItemDialog-66621fe8.esm.js} +3 -3
  8. package/dist/{CreateItemDialog-a0cab315.cjs.js → CreateItemDialog-96b044ce.cjs.js} +4 -4
  9. package/dist/{Field-47f85161.esm.js → Field-1820c4e6.esm.js} +1 -0
  10. package/dist/{Field-ed8d7627.cjs.js → Field-38d3cdf9.cjs.js} +1 -0
  11. package/dist/GraphQLErrorNotice-7594a9f8.esm.js +64 -0
  12. package/dist/GraphQLErrorNotice-c8890f80.cjs.js +66 -0
  13. package/dist/{PageContainer-5ae731cc.esm.js → PageContainer-355cfbfa.esm.js} +362 -156
  14. package/dist/{PageContainer-abd7159f.cjs.js → PageContainer-4095555a.cjs.js} +361 -155
  15. package/dist/{context-af9957ed.esm.js → context-2924eaaa.esm.js} +53 -58
  16. package/dist/{context-b5204629.cjs.js → context-2ce61d0b.cjs.js} +53 -58
  17. package/dist/declarations/src/admin-ui/components/GraphQLErrorNotice.d.ts.map +1 -1
  18. package/dist/declarations/src/admin-ui/components/Navigation.d.ts.map +1 -1
  19. package/dist/declarations/src/admin-ui/components/PageContainer.d.ts.map +1 -1
  20. package/dist/declarations/src/admin-ui/context.d.ts.map +1 -1
  21. package/dist/declarations/src/admin-ui/utils/useCreateItem.d.ts.map +1 -1
  22. package/dist/declarations/src/fields/types/bigInt/views/index.d.ts +2 -1
  23. package/dist/declarations/src/fields/types/bigInt/views/index.d.ts.map +1 -1
  24. package/dist/declarations/src/fields/types/bytes/views/index.d.ts +2 -1
  25. package/dist/declarations/src/fields/types/bytes/views/index.d.ts.map +1 -1
  26. package/dist/declarations/src/fields/types/calendarDay/views/index.d.ts.map +1 -1
  27. package/dist/declarations/src/fields/types/decimal/views/index.d.ts +2 -1
  28. package/dist/declarations/src/fields/types/decimal/views/index.d.ts.map +1 -1
  29. package/dist/declarations/src/fields/types/float/views/index.d.ts +2 -1
  30. package/dist/declarations/src/fields/types/float/views/index.d.ts.map +1 -1
  31. package/dist/declarations/src/fields/types/integer/views/index.d.ts +2 -1
  32. package/dist/declarations/src/fields/types/integer/views/index.d.ts.map +1 -1
  33. package/dist/declarations/src/fields/types/json/views/index.d.ts.map +1 -1
  34. package/dist/declarations/src/fields/types/multiselect/views/index.d.ts.map +1 -1
  35. package/dist/declarations/src/fields/types/relationship/views/index.d.ts.map +1 -1
  36. package/dist/declarations/src/fields/types/select/views/index.d.ts.map +1 -1
  37. package/dist/declarations/src/fields/types/text/views/index.d.ts +2 -1
  38. package/dist/declarations/src/fields/types/text/views/index.d.ts.map +1 -1
  39. package/dist/declarations/src/fields/types/timestamp/views/index.d.ts.map +1 -1
  40. package/dist/declarations/src/fields/types/virtual/views/index.d.ts.map +1 -1
  41. package/dist/declarations/src/internal-unstable/admin-ui/pages/HomePage/index.d.ts.map +1 -1
  42. package/dist/declarations/src/internal-unstable/admin-ui/pages/ItemPage/index.d.ts.map +1 -1
  43. package/dist/pick-4c785a54.esm.js +34 -0
  44. package/dist/pick-906341bb.cjs.js +37 -0
  45. package/dist/{useCreateItem-1f94d252.esm.js → useCreateItem-36a75f1c.esm.js} +26 -26
  46. package/dist/{useCreateItem-1be4987e.cjs.js → useCreateItem-acf06f77.cjs.js} +37 -37
  47. package/dist/{useFilter-acc9d413.cjs.js → useFilter-c29f17a8.cjs.js} +1 -1
  48. package/dist/{useFilter-9b6db1f9.esm.js → useFilter-f79b2abb.esm.js} +1 -1
  49. package/dist/{Fields-956d9a14.esm.js → usePreventNavigation-093389dd.esm.js} +28 -2
  50. package/dist/{Fields-e2c28056.cjs.js → usePreventNavigation-d4f9f4fa.cjs.js} +27 -0
  51. package/fields/types/bigInt/views/dist/nixxie-cms-core-fields-types-bigInt-views.cjs.js +8 -0
  52. package/fields/types/bigInt/views/dist/nixxie-cms-core-fields-types-bigInt-views.esm.js +8 -1
  53. package/fields/types/bytes/views/dist/nixxie-cms-core-fields-types-bytes-views.cjs.js +14 -3
  54. package/fields/types/bytes/views/dist/nixxie-cms-core-fields-types-bytes-views.esm.js +15 -5
  55. package/fields/types/calendarDay/views/dist/nixxie-cms-core-fields-types-calendarDay-views.cjs.js +2 -1
  56. package/fields/types/calendarDay/views/dist/nixxie-cms-core-fields-types-calendarDay-views.esm.js +2 -1
  57. package/fields/types/decimal/views/dist/nixxie-cms-core-fields-types-decimal-views.cjs.js +10 -1
  58. package/fields/types/decimal/views/dist/nixxie-cms-core-fields-types-decimal-views.esm.js +10 -2
  59. package/fields/types/file/views/dist/nixxie-cms-core-fields-types-file-views.cjs.js +1 -1
  60. package/fields/types/file/views/dist/nixxie-cms-core-fields-types-file-views.esm.js +2 -2
  61. package/fields/types/float/views/dist/nixxie-cms-core-fields-types-float-views.cjs.js +10 -1
  62. package/fields/types/float/views/dist/nixxie-cms-core-fields-types-float-views.esm.js +10 -2
  63. package/fields/types/image/views/dist/nixxie-cms-core-fields-types-image-views.cjs.js +2 -1
  64. package/fields/types/image/views/dist/nixxie-cms-core-fields-types-image-views.esm.js +2 -1
  65. package/fields/types/integer/views/dist/nixxie-cms-core-fields-types-integer-views.cjs.js +8 -0
  66. package/fields/types/integer/views/dist/nixxie-cms-core-fields-types-integer-views.esm.js +8 -1
  67. package/fields/types/json/views/dist/nixxie-cms-core-fields-types-json-views.cjs.js +19 -4
  68. package/fields/types/json/views/dist/nixxie-cms-core-fields-types-json-views.esm.js +19 -4
  69. package/fields/types/multiselect/views/dist/nixxie-cms-core-fields-types-multiselect-views.cjs.js +18 -3
  70. package/fields/types/multiselect/views/dist/nixxie-cms-core-fields-types-multiselect-views.esm.js +18 -3
  71. package/fields/types/password/views/dist/nixxie-cms-core-fields-types-password-views.cjs.js +1 -1
  72. package/fields/types/password/views/dist/nixxie-cms-core-fields-types-password-views.esm.js +1 -1
  73. package/fields/types/relationship/views/dist/nixxie-cms-core-fields-types-relationship-views.cjs.js +9 -7
  74. package/fields/types/relationship/views/dist/nixxie-cms-core-fields-types-relationship-views.esm.js +9 -7
  75. package/fields/types/select/views/dist/nixxie-cms-core-fields-types-select-views.cjs.js +3 -2
  76. package/fields/types/select/views/dist/nixxie-cms-core-fields-types-select-views.esm.js +3 -2
  77. package/fields/types/text/views/dist/nixxie-cms-core-fields-types-text-views.cjs.js +14 -3
  78. package/fields/types/text/views/dist/nixxie-cms-core-fields-types-text-views.esm.js +15 -5
  79. package/fields/types/timestamp/views/dist/nixxie-cms-core-fields-types-timestamp-views.cjs.js +2 -1
  80. package/fields/types/timestamp/views/dist/nixxie-cms-core-fields-types-timestamp-views.esm.js +2 -1
  81. package/fields/types/virtual/views/dist/nixxie-cms-core-fields-types-virtual-views.cjs.js +13 -1
  82. package/fields/types/virtual/views/dist/nixxie-cms-core-fields-types-virtual-views.esm.js +14 -2
  83. package/internal-unstable/admin-ui/pages/App/dist/nixxie-cms-core-internal-unstable-admin-ui-pages-App.cjs.js +3 -3
  84. package/internal-unstable/admin-ui/pages/App/dist/nixxie-cms-core-internal-unstable-admin-ui-pages-App.esm.js +3 -3
  85. package/internal-unstable/admin-ui/pages/CreateItemPage/dist/nixxie-cms-core-internal-unstable-admin-ui-pages-CreateItemPage.cjs.js +7 -7
  86. package/internal-unstable/admin-ui/pages/CreateItemPage/dist/nixxie-cms-core-internal-unstable-admin-ui-pages-CreateItemPage.esm.js +6 -6
  87. package/internal-unstable/admin-ui/pages/HomePage/dist/nixxie-cms-core-internal-unstable-admin-ui-pages-HomePage.cjs.js +34 -33
  88. package/internal-unstable/admin-ui/pages/HomePage/dist/nixxie-cms-core-internal-unstable-admin-ui-pages-HomePage.esm.js +35 -34
  89. package/internal-unstable/admin-ui/pages/ItemPage/dist/nixxie-cms-core-internal-unstable-admin-ui-pages-ItemPage.cjs.js +53 -13
  90. package/internal-unstable/admin-ui/pages/ItemPage/dist/nixxie-cms-core-internal-unstable-admin-ui-pages-ItemPage.esm.js +52 -12
  91. package/internal-unstable/admin-ui/pages/ListPage/dist/nixxie-cms-core-internal-unstable-admin-ui-pages-ListPage.cjs.js +36 -25
  92. package/internal-unstable/admin-ui/pages/ListPage/dist/nixxie-cms-core-internal-unstable-admin-ui-pages-ListPage.esm.js +36 -25
  93. package/package.json +2 -2
  94. package/src/admin-ui/components/CommandPalette.tsx +134 -27
  95. package/src/admin-ui/components/CreateButtonLink.tsx +20 -46
  96. package/src/admin-ui/components/GraphQLErrorNotice.tsx +39 -33
  97. package/src/admin-ui/components/Logo.tsx +5 -5
  98. package/src/admin-ui/components/Navigation.tsx +41 -27
  99. package/src/admin-ui/components/PageContainer.tsx +171 -15
  100. package/src/admin-ui/components/WelcomeDialog.tsx +14 -14
  101. package/src/admin-ui/context.tsx +5 -2
  102. package/src/admin-ui/utils/useCreateItem.ts +21 -1
  103. package/src/fields/types/bigInt/views/index.tsx +10 -1
  104. package/src/fields/types/bytes/views/index.tsx +14 -1
  105. package/src/fields/types/calendarDay/views/index.tsx +2 -1
  106. package/src/fields/types/decimal/views/index.tsx +7 -1
  107. package/src/fields/types/file/views/Field.tsx +1 -1
  108. package/src/fields/types/float/views/index.tsx +7 -1
  109. package/src/fields/types/image/views/index.tsx +1 -1
  110. package/src/fields/types/integer/views/index.tsx +5 -0
  111. package/src/fields/types/json/views/index.tsx +20 -2
  112. package/src/fields/types/multiselect/views/index.tsx +7 -3
  113. package/src/fields/types/password/views/index.tsx +1 -1
  114. package/src/fields/types/relationship/views/index.tsx +1 -0
  115. package/src/fields/types/select/views/index.tsx +2 -1
  116. package/src/fields/types/text/views/index.tsx +14 -1
  117. package/src/fields/types/timestamp/views/__tests__/index.tsx +68 -68
  118. package/src/fields/types/timestamp/views/index.tsx +2 -1
  119. package/src/fields/types/virtual/views/index.tsx +17 -2
  120. package/src/internal-unstable/admin-ui/pages/HomePage/index.tsx +40 -31
  121. package/src/internal-unstable/admin-ui/pages/ItemPage/index.tsx +36 -3
  122. package/src/internal-unstable/admin-ui/pages/ListPage/PaginationControls.tsx +20 -33
  123. package/src/internal-unstable/admin-ui/pages/ListPage/index.tsx +24 -16
  124. package/tests/conditional-filters.test.ts +333 -326
  125. package/dist/GraphQLErrorNotice-cd74180d.cjs.js +0 -57
  126. package/dist/GraphQLErrorNotice-d9f0931b.esm.js +0 -55
  127. package/dist/pick-5fe45878.cjs.js +0 -71
  128. package/dist/pick-b7ef3115.esm.js +0 -68
@@ -1,7 +1,7 @@
1
1
  import { type ReactNode, type PropsWithChildren, useState, type ChangeEvent } from 'react'
2
2
  import { useRouter } from 'next/router'
3
3
 
4
- import { css } from '@keystar/ui/style'
4
+ import { css, tokenSchema } from '@keystar/ui/style'
5
5
 
6
6
  import type { CollectionMeta } from '../../types'
7
7
  import { useNixxie } from '../context'
@@ -27,10 +27,12 @@ type NavItemProps = {
27
27
 
28
28
  export function NavItem({ href, children, onClick, indent }: NavItemProps) {
29
29
  const router = useRouter()
30
- const segment = href.split('/')[1]
30
+ // Active on an exact match, or when the current route is nested under `href`
31
+ // (prefix match on a path boundary). Dashboard ('/') is only active on an
32
+ // exact match so it doesn't light up for every nested route.
31
33
  const isActive =
32
34
  router.pathname === href ||
33
- (segment && router.pathname.split('/')[1] === segment && href !== '/')
35
+ (href !== '/' && router.pathname.startsWith(href + '/'))
34
36
 
35
37
  return (
36
38
  <a
@@ -49,8 +51,8 @@ export function NavItem({ href, children, onClick, indent }: NavItemProps) {
49
51
  paddingBlock: '7px',
50
52
  fontSize: 13,
51
53
  fontWeight: isActive ? 500 : 400,
52
- color: isActive ? '#0a0a0a' : '#636363',
53
- backgroundColor: isActive ? '#f5f5f5' : 'transparent',
54
+ color: isActive ? tokenSchema.color.foreground.neutralEmphasis : tokenSchema.color.foreground.neutralSecondary,
55
+ backgroundColor: isActive ? tokenSchema.color.background.surfaceSecondary : 'transparent',
54
56
  textDecoration: 'none',
55
57
  borderRadius: '0 6px 6px 0',
56
58
  marginRight: '12px',
@@ -60,8 +62,8 @@ export function NavItem({ href, children, onClick, indent }: NavItemProps) {
60
62
  transition: 'color 120ms, background-color 120ms',
61
63
 
62
64
  '&:hover': {
63
- color: '#0a0a0a',
64
- backgroundColor: '#f5f5f5',
65
+ color: tokenSchema.color.foreground.neutralEmphasis,
66
+ backgroundColor: tokenSchema.color.background.surfaceSecondary,
65
67
  },
66
68
  })}
67
69
  >
@@ -75,7 +77,7 @@ export function NavItem({ href, children, onClick, indent }: NavItemProps) {
75
77
  bottom: '20%',
76
78
  width: 2,
77
79
  borderRadius: '0 2px 2px 0',
78
- backgroundColor: '#000000',
80
+ backgroundColor: tokenSchema.color.scale.black,
79
81
  })}
80
82
  />
81
83
  )}
@@ -113,7 +115,7 @@ function SectionLabel({ children, action }: { children: ReactNode; action?: Reac
113
115
  fontWeight: 600,
114
116
  letterSpacing: '0.10em',
115
117
  textTransform: 'uppercase',
116
- color: '#b8b8b8',
118
+ color: tokenSchema.color.foreground.neutralSecondary,
117
119
  })}
118
120
  >
119
121
  {children}
@@ -134,7 +136,7 @@ function NavDivider() {
134
136
  height: 1,
135
137
  marginInline: '16px',
136
138
  marginBlock: '6px',
137
- backgroundColor: '#f2f2f2',
139
+ backgroundColor: tokenSchema.color.border.muted,
138
140
  })}
139
141
  />
140
142
  )
@@ -158,21 +160,21 @@ function SearchTrigger({ onClick }: { onClick?: () => void }) {
158
160
  paddingInline: '10px',
159
161
  paddingBlock: '7px',
160
162
  borderRadius: 6,
161
- border: '1px solid #ebebeb',
162
- backgroundColor: '#f5f5f5',
163
- color: '#a3a3a3',
163
+ border: `1px solid ${tokenSchema.color.border.muted}`,
164
+ backgroundColor: tokenSchema.color.background.surfaceSecondary,
165
+ color: tokenSchema.color.foreground.neutralSecondary,
164
166
  fontSize: 12.5,
165
167
  cursor: 'pointer',
166
168
  textAlign: 'left',
167
169
  transition: 'border-color 140ms, background 140ms, color 140ms',
168
170
 
169
171
  '&:hover': {
170
- borderColor: '#dedede',
171
- backgroundColor: '#efefef',
172
- color: '#737373',
172
+ borderColor: tokenSchema.color.border.emphasis,
173
+ backgroundColor: tokenSchema.color.background.surfaceSecondary,
174
+ color: tokenSchema.color.foreground.neutralSecondary,
173
175
  },
174
176
  '&:focus-visible': {
175
- outline: '2px solid #000',
177
+ outline: `2px solid ${tokenSchema.color.scale.black}`,
176
178
  outlineOffset: 2,
177
179
  },
178
180
  })}
@@ -185,7 +187,7 @@ function SearchTrigger({ onClick }: { onClick?: () => void }) {
185
187
  <kbd
186
188
  className={css({
187
189
  fontSize: 10.5,
188
- color: '#c8c8c8',
190
+ color: tokenSchema.color.foreground.neutralSecondary,
189
191
  fontFamily: 'inherit',
190
192
  letterSpacing: '0.02em',
191
193
  })}
@@ -213,15 +215,15 @@ function NavSearch({ value, onChange }: { value: string; onChange: (v: string) =
213
215
  paddingInline: '10px',
214
216
  paddingBlock: '5px',
215
217
  fontSize: 12.5,
216
- border: '1px solid #ebebeb',
218
+ border: `1px solid ${tokenSchema.color.border.muted}`,
217
219
  borderRadius: 5,
218
- backgroundColor: '#f5f5f5',
219
- color: '#0a0a0a',
220
+ backgroundColor: tokenSchema.color.background.surfaceSecondary,
221
+ color: tokenSchema.color.foreground.neutralEmphasis,
220
222
  outline: 'none',
221
223
  boxSizing: 'border-box',
222
224
  transition: 'border-color 140ms',
223
- '&:focus': { borderColor: '#b0b0b0' },
224
- '&::placeholder': { color: '#c8c8c8' },
225
+ '&:focus': { borderColor: tokenSchema.color.alias.borderHovered },
226
+ '&::placeholder': { color: tokenSchema.color.foreground.neutralTertiary },
225
227
  '&::-webkit-search-cancel-button': { display: 'none' },
226
228
  })}
227
229
  />
@@ -257,7 +259,13 @@ function CollectionsSection({
257
259
  {showSearch && <NavSearch value={search} onChange={setSearch} />}
258
260
 
259
261
  {filtered.length === 0 ? (
260
- <p className={css({ margin: '4px 16px', fontSize: 12.5, color: '#c8c8c8' })}>
262
+ <p
263
+ className={css({
264
+ margin: '4px 16px',
265
+ fontSize: 12.5,
266
+ color: tokenSchema.color.foreground.neutralSecondary,
267
+ })}
268
+ >
261
269
  No results
262
270
  </p>
263
271
  ) : (
@@ -270,7 +278,7 @@ function CollectionsSection({
270
278
  marginLeft: 6,
271
279
  fontSize: 10,
272
280
  fontWeight: 500,
273
- color: '#c8c8c8',
281
+ color: tokenSchema.color.foreground.neutralSecondary,
274
282
  letterSpacing: '0.04em',
275
283
  textTransform: 'uppercase',
276
284
  })}
@@ -326,7 +334,13 @@ export function Navigation({ onNavItemClick, onCmdK }: InternalNavigationProps)
326
334
  {visibleLists.length > 0 ? (
327
335
  <CollectionsSection lists={visibleLists} onNavItemClick={onNavItemClick} />
328
336
  ) : (
329
- <p className={css({ margin: '8px 16px', fontSize: 12.5, color: '#c8c8c8' })}>
337
+ <p
338
+ className={css({
339
+ margin: '8px 16px',
340
+ fontSize: 12.5,
341
+ color: tokenSchema.color.foreground.neutralSecondary,
342
+ })}
343
+ >
330
344
  No collections
331
345
  </p>
332
346
  )}
@@ -374,7 +388,7 @@ export function NavFooter({ children }: PropsWithChildren) {
374
388
  className={css({
375
389
  paddingInline: '12px',
376
390
  paddingBlock: '10px',
377
- borderTop: '1px solid #f2f2f2',
391
+ borderTop: `1px solid ${tokenSchema.color.border.muted}`,
378
392
  marginTop: 'auto',
379
393
  })}
380
394
  >
@@ -1,7 +1,13 @@
1
1
  import NextHead from 'next/head'
2
- import { type HTMLAttributes, type ReactNode, useEffect, useState } from 'react'
2
+ import {
3
+ type HTMLAttributes,
4
+ type ReactNode,
5
+ useEffect,
6
+ useRef,
7
+ useState,
8
+ } from 'react'
3
9
 
4
- import { css } from '@keystar/ui/style'
10
+ import { css, tokenSchema } from '@keystar/ui/style'
5
11
 
6
12
  import { Logo } from './Logo'
7
13
  import { Navigation } from './Navigation'
@@ -17,7 +23,7 @@ export function PageWrapper(props: HTMLAttributes<HTMLElement>) {
17
23
  display: 'flex',
18
24
  height: '100vh',
19
25
  overflow: 'hidden',
20
- backgroundColor: '#fafafa',
26
+ backgroundColor: tokenSchema.color.background.surface,
21
27
  fontFamily: "'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
22
28
  })}
23
29
  {...props}
@@ -25,6 +31,51 @@ export function PageWrapper(props: HTMLAttributes<HTMLElement>) {
25
31
  )
26
32
  }
27
33
 
34
+ // ---- Skip to content (WCAG 2.4.1 Bypass Blocks) ----
35
+ function SkipLink() {
36
+ return (
37
+ <a
38
+ href="#main-content"
39
+ className={css({
40
+ // Visually hidden, off-screen by default — kept out of the layout for mouse users.
41
+ position: 'absolute',
42
+ top: 0,
43
+ left: 0,
44
+ width: 1,
45
+ height: 1,
46
+ padding: 0,
47
+ margin: -1,
48
+ overflow: 'hidden',
49
+ clip: 'rect(0 0 0 0)',
50
+ whiteSpace: 'nowrap',
51
+ border: 0,
52
+ zIndex: 60,
53
+
54
+ // Reveal on keyboard focus.
55
+ '&:focus, &:focus-visible': {
56
+ width: 'auto',
57
+ height: 'auto',
58
+ padding: '10px 16px',
59
+ margin: 8,
60
+ clip: 'auto',
61
+ overflow: 'visible',
62
+ backgroundColor: tokenSchema.color.background.canvas,
63
+ color: tokenSchema.color.foreground.neutralEmphasis,
64
+ borderRadius: 6,
65
+ boxShadow: `0 2px 8px ${tokenSchema.color.shadow.regular}`,
66
+ outline: `2px solid ${tokenSchema.color.foreground.neutralEmphasis}`,
67
+ outlineOffset: 2,
68
+ textDecoration: 'none',
69
+ fontSize: 14,
70
+ fontWeight: 500,
71
+ },
72
+ })}
73
+ >
74
+ Skip to content
75
+ </a>
76
+ )
77
+ }
78
+
28
79
  // ---- Mobile overlay ----
29
80
  function Backdrop({ isVisible, onClick }: { isVisible: boolean; onClick: () => void }) {
30
81
  return (
@@ -34,7 +85,7 @@ function Backdrop({ isVisible, onClick }: { isVisible: boolean; onClick: () => v
34
85
  className={css({
35
86
  position: 'fixed',
36
87
  inset: 0,
37
- backgroundColor: 'rgba(10,10,10,0.35)',
88
+ backgroundColor: tokenSchema.color.alias.blanket,
38
89
  zIndex: 40,
39
90
  transition: 'opacity 200ms',
40
91
  opacity: isVisible ? 1 : 0,
@@ -47,6 +98,9 @@ function Backdrop({ isVisible, onClick }: { isVisible: boolean; onClick: () => v
47
98
  }
48
99
 
49
100
  // ---- Sidebar — pure white, precise ----
101
+ const FOCUSABLE_SELECTOR =
102
+ 'a[href],button:not([disabled]),input:not([disabled]),[tabindex]:not([tabindex="-1"])'
103
+
50
104
  function Sidebar({
51
105
  isOpen,
52
106
  onClose,
@@ -56,10 +110,93 @@ function Sidebar({
56
110
  onClose: () => void
57
111
  onCmdK: () => void
58
112
  }) {
113
+ const asideRef = useRef<HTMLElement>(null)
114
+ // Element to restore focus to (the hamburger) once the drawer closes.
115
+ const lastFocusedRef = useRef<HTMLElement | null>(null)
116
+
117
+ // Mobile drawer a11y: focus trap, Escape-to-close, initial focus, and focus
118
+ // restore. `isOpen` is only ever true on mobile (the resize handler forces it
119
+ // false at >=768px), so gating on it doubles as a mobile gate.
120
+ useEffect(() => {
121
+ if (!isOpen) return
122
+
123
+ const aside = asideRef.current
124
+ if (!aside) return
125
+
126
+ // Save the currently focused element so we can restore it on close.
127
+ try {
128
+ const active = document.activeElement
129
+ lastFocusedRef.current = active instanceof HTMLElement ? active : null
130
+ } catch {
131
+ lastFocusedRef.current = null
132
+ }
133
+
134
+ const getFocusable = (): HTMLElement[] => {
135
+ try {
136
+ return Array.from(
137
+ aside.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)
138
+ ).filter(el => el.offsetParent !== null || el === document.activeElement)
139
+ } catch {
140
+ return []
141
+ }
142
+ }
143
+
144
+ // Move initial focus into the drawer.
145
+ try {
146
+ getFocusable()[0]?.focus()
147
+ } catch {
148
+ /* noop */
149
+ }
150
+
151
+ function onKeyDown(e: KeyboardEvent) {
152
+ if (e.key === 'Escape') {
153
+ e.preventDefault()
154
+ onClose()
155
+ return
156
+ }
157
+ if (e.key !== 'Tab') return
158
+
159
+ const focusable = getFocusable()
160
+ if (focusable.length === 0) {
161
+ // Nothing focusable inside — keep focus from escaping anyway.
162
+ e.preventDefault()
163
+ return
164
+ }
165
+
166
+ const first = focusable[0]
167
+ const last = focusable[focusable.length - 1]
168
+ const current = document.activeElement
169
+
170
+ if (e.shiftKey) {
171
+ if (current === first || !aside?.contains(current)) {
172
+ e.preventDefault()
173
+ last.focus()
174
+ }
175
+ } else if (current === last || !aside?.contains(current)) {
176
+ e.preventDefault()
177
+ first.focus()
178
+ }
179
+ }
180
+
181
+ document.addEventListener('keydown', onKeyDown, true)
182
+
183
+ return () => {
184
+ document.removeEventListener('keydown', onKeyDown, true)
185
+ // Restore focus to the trigger that opened the drawer.
186
+ try {
187
+ lastFocusedRef.current?.focus()
188
+ } catch {
189
+ /* noop */
190
+ }
191
+ }
192
+ }, [isOpen, onClose])
193
+
59
194
  return (
60
195
  <>
61
196
  <Backdrop isVisible={isOpen} onClick={onClose} />
62
197
  <aside
198
+ id="nixxie-sidebar"
199
+ ref={asideRef}
63
200
  className={css({
64
201
  position: 'fixed',
65
202
  top: 0,
@@ -69,14 +206,19 @@ function Sidebar({
69
206
  zIndex: 50,
70
207
  display: 'flex',
71
208
  flexDirection: 'column',
72
- backgroundColor: '#ffffff',
73
- borderRight: '1px solid #ebebeb',
209
+ backgroundColor: tokenSchema.color.background.canvas,
210
+ borderRight: `1px solid ${tokenSchema.color.border.muted}`,
74
211
  transform: isOpen ? 'translateX(0)' : `translateX(-${SIDEBAR_WIDTH}px)`,
212
+ // Closed off-canvas drawer must leave the tab order / AT tree on
213
+ // mobile; `visibility:hidden` removes its descendants from both.
214
+ visibility: isOpen ? 'visible' : 'hidden',
75
215
  transition: 'transform 220ms cubic-bezier(0.4,0,0.2,1)',
76
216
 
77
217
  '@media (min-width: 768px)': {
78
218
  position: 'relative',
79
219
  transform: 'translateX(0)',
220
+ // Persistent desktop rail is always reachable.
221
+ visibility: 'visible',
80
222
  flexShrink: 0,
81
223
  },
82
224
  })}
@@ -88,7 +230,7 @@ function Sidebar({
88
230
  alignItems: 'center',
89
231
  height: TOPBAR_HEIGHT,
90
232
  paddingInline: '16px',
91
- borderBottom: '1px solid #f2f2f2',
233
+ borderBottom: `1px solid ${tokenSchema.color.border.muted}`,
92
234
  flexShrink: 0,
93
235
  })}
94
236
  >
@@ -103,7 +245,10 @@ function Sidebar({
103
245
  overflowX: 'hidden',
104
246
  WebkitOverflowScrolling: 'touch',
105
247
  '&::-webkit-scrollbar': { width: 3 },
106
- '&::-webkit-scrollbar-thumb': { background: '#ebebeb', borderRadius: 3 },
248
+ '&::-webkit-scrollbar-thumb': {
249
+ background: tokenSchema.color.border.muted,
250
+ borderRadius: 3,
251
+ },
107
252
  })}
108
253
  >
109
254
  <Navigation onNavItemClick={onClose} onCmdK={onCmdK} />
@@ -131,8 +276,8 @@ function TopBar({
131
276
  height: TOPBAR_HEIGHT,
132
277
  paddingInline: '24px',
133
278
  gap: '14px',
134
- backgroundColor: '#ffffff',
135
- borderBottom: '1px solid #ebebeb',
279
+ backgroundColor: tokenSchema.color.background.canvas,
280
+ borderBottom: `1px solid ${tokenSchema.color.border.muted}`,
136
281
  flexShrink: 0,
137
282
  zIndex: 30,
138
283
  })}
@@ -142,6 +287,7 @@ function TopBar({
142
287
  onClick={onMenuClick}
143
288
  aria-label={isSidebarOpen ? 'Close menu' : 'Open menu'}
144
289
  aria-expanded={isSidebarOpen}
290
+ aria-controls="nixxie-sidebar"
145
291
  className={css({
146
292
  display: 'inline-flex',
147
293
  alignItems: 'center',
@@ -149,13 +295,16 @@ function TopBar({
149
295
  width: 32,
150
296
  height: 32,
151
297
  borderRadius: 6,
152
- border: '1px solid #ebebeb',
298
+ border: `1px solid ${tokenSchema.color.border.muted}`,
153
299
  background: 'transparent',
154
300
  cursor: 'pointer',
155
301
  flexShrink: 0,
156
- color: '#a3a3a3',
302
+ color: tokenSchema.color.foreground.neutralTertiary,
157
303
  transition: 'color 130ms, background 130ms',
158
- '&:hover': { background: '#f5f5f5', color: '#0a0a0a' },
304
+ '&:hover': {
305
+ background: tokenSchema.color.background.surfaceSecondary,
306
+ color: tokenSchema.color.foreground.neutralEmphasis,
307
+ },
159
308
  '@media (min-width: 768px)': { display: 'none' },
160
309
  })}
161
310
  >
@@ -222,6 +371,8 @@ function TopBar({
222
371
  function MainContent(props: HTMLAttributes<HTMLElement>) {
223
372
  return (
224
373
  <main
374
+ id="main-content"
375
+ tabIndex={-1}
225
376
  className={css({
226
377
  flex: 1,
227
378
  display: 'flex',
@@ -245,8 +396,11 @@ function ContentScroller(props: HTMLAttributes<HTMLDivElement>) {
245
396
  overflowX: 'hidden',
246
397
  WebkitOverflowScrolling: 'touch',
247
398
  '&::-webkit-scrollbar': { width: 5 },
248
- '&::-webkit-scrollbar-thumb': { background: '#e8e8e8', borderRadius: 4 },
249
- '&::-webkit-scrollbar-thumb:hover': { background: '#d4d4d4' },
399
+ '&::-webkit-scrollbar-thumb': {
400
+ background: tokenSchema.color.border.muted,
401
+ borderRadius: 4,
402
+ },
403
+ '&::-webkit-scrollbar-thumb:hover': { background: tokenSchema.color.border.emphasis },
250
404
  })}
251
405
  {...props}
252
406
  />
@@ -290,6 +444,8 @@ export function PageContainer({ children, header, title }: PageContainerProps) {
290
444
  <title key="title">{title ? `Nixxie – ${title}` : 'Nixxie'}</title>
291
445
  </NextHead>
292
446
 
447
+ <SkipLink />
448
+
293
449
  <Sidebar
294
450
  isOpen={isSidebarOpen}
295
451
  onClose={() => setSidebarOpen(false)}
@@ -20,7 +20,7 @@ function validateEmail(value: string) {
20
20
  }
21
21
 
22
22
  export function WelcomeDialog() {
23
- const [subscribe, setSubscribe] = useState(['nixxie'])
23
+ const [subscribe, setSubscribe] = useState<string[]>([])
24
24
  const [email, setEmail] = useState('')
25
25
  const [error, setError] = useState<string | null>(null)
26
26
  const [loading, setLoading] = useState(false)
@@ -33,25 +33,26 @@ export function WelcomeDialog() {
33
33
 
34
34
  // Check if user wants to subscribe and a valid email address
35
35
  if (subscribe.length) {
36
- setLoading(true)
37
-
36
+ // Synchronous validation should not show pending state
38
37
  if (!validateEmail(email)) {
39
38
  setError(emailValidationMessage)
40
39
  return
41
40
  }
42
41
 
42
+ setLoading(true)
43
+
43
44
  const tags = ['source:@nixxie-cms/auth']
44
45
  subscribe.forEach(list => tags.push(`list:${list}`))
45
46
 
46
- const res = await fetch(newsletterUrl, {
47
- method: 'POST',
48
- headers: {
49
- 'Content-Type': 'application/json',
50
- },
51
- body: JSON.stringify({ email, tags }),
52
- })
53
-
54
47
  try {
48
+ const res = await fetch(newsletterUrl, {
49
+ method: 'POST',
50
+ headers: {
51
+ 'Content-Type': 'application/json',
52
+ },
53
+ body: JSON.stringify({ email, tags }),
54
+ })
55
+
55
56
  if (res.status !== 200) {
56
57
  const { error } = await res.json()
57
58
  setError(error)
@@ -61,9 +62,9 @@ export function WelcomeDialog() {
61
62
  // network errors or failed parse
62
63
  setError(e.message.toString())
63
64
  return
65
+ } finally {
66
+ setLoading(false)
64
67
  }
65
-
66
- setLoading(false)
67
68
  }
68
69
 
69
70
  dialog.dismiss()
@@ -101,7 +102,6 @@ export function WelcomeDialog() {
101
102
  />
102
103
  <CheckboxGroup onChange={setSubscribe} value={subscribe}>
103
104
  <Checkbox value="nixxie">Nixxie news</Checkbox>
104
- <Checkbox value="nixxie">Nixxie news</Checkbox>
105
105
  </CheckboxGroup>
106
106
  </VStack>
107
107
  </form>
@@ -142,8 +142,7 @@ function InternalNixxieProvider({
142
142
  useEffect(() => {
143
143
  injectGlobal({
144
144
  body: {
145
- fontFamily:
146
- "'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
145
+ fontFamily: tokenSchema.typography.fontFamily.base,
147
146
  WebkitFontSmoothing: 'antialiased',
148
147
  MozOsxFontSmoothing: 'grayscale',
149
148
  },
@@ -165,6 +164,10 @@ function InternalNixxieProvider({
165
164
  if (loading) return null
166
165
  // if (!meta) return null
167
166
  return (
167
+ // The app is intentionally pinned to the light color scheme for now. Now
168
+ // that the rest of the chrome is token-based, enabling system/dark is a
169
+ // one-line change (drop `colorScheme="light"` here and the
170
+ // `[data-nixxie-content]` override below) pending a visual-QA pass.
168
171
  <KeystarProvider router={keystarRouter} colorScheme="light">
169
172
  <ClientSideOnlyDocumentElement bodyBackground="canvas" />
170
173
  <NextHead>
@@ -14,6 +14,22 @@ import type { CollectionMeta } from '../../types'
14
14
  import { type ErrorLike, gql, type TypedDocumentNode, useMutation } from '../apollo'
15
15
  import { usePreventNavigation } from './usePreventNavigation'
16
16
 
17
+ // after forcing validation, move focus to the first invalid field so the user
18
+ // can see and fix it. runs on the next frame so the DOM reflects the new
19
+ // aria-invalid state, and is fully defensive so it can never throw.
20
+ function focusFirstInvalidField() {
21
+ requestAnimationFrame(() => {
22
+ try {
23
+ const el = document.querySelector<HTMLElement>('[aria-invalid="true"]')
24
+ if (!el) return
25
+ el.focus({ preventScroll: false })
26
+ el.scrollIntoView({ block: 'center', behavior: 'smooth' })
27
+ } catch {
28
+ // ignore — focusing is a best-effort enhancement
29
+ }
30
+ })
31
+ }
32
+
17
33
  type CreateItemHookResult = {
18
34
  state: 'editing' | 'loading' | 'created'
19
35
  shouldPreventNavigation: boolean
@@ -81,7 +97,11 @@ export function useCreateItem(list: CollectionMeta): CreateItemHookResult {
81
97
  const newForceValidation = invalidFields.size !== 0
82
98
  setForceValidation(newForceValidation)
83
99
 
84
- if (newForceValidation) return
100
+ if (newForceValidation) {
101
+ toastQueue.critical('Please fix the highlighted field(s) before saving.')
102
+ focusFirstInvalidField()
103
+ return
104
+ }
85
105
 
86
106
  let outputData: { item: { id: string; label: string | null } }
87
107
  try {
@@ -6,7 +6,12 @@ import { TextField } from '@keystar/ui/text-field'
6
6
  import { Heading, Text } from '@keystar/ui/typography'
7
7
 
8
8
  import type { SimpleFieldTypeInfo } from '../../../../types'
9
- import type { FieldController, FieldControllerConfig, FieldProps } from '../../../../types'
9
+ import type {
10
+ CellComponent,
11
+ FieldController,
12
+ FieldControllerConfig,
13
+ FieldProps,
14
+ } from '../../../../types'
10
15
  import { entriesTyped } from '../../../../lib/core/utils'
11
16
 
12
17
  const TYPE_OPERATOR_MAP = {
@@ -252,3 +257,7 @@ export function Field({
252
257
  />
253
258
  )
254
259
  }
260
+
261
+ export const Cell: CellComponent<typeof controller> = ({ value }) => {
262
+ return value != null && value !== '' ? <Text>{value}</Text> : null
263
+ }
@@ -1,10 +1,12 @@
1
1
  import { TextField } from '@keystar/ui/text-field'
2
+ import { Text } from '@keystar/ui/typography'
2
3
  import { useState } from 'react'
3
4
 
4
5
  import type { TextFieldMeta } from '..'
5
6
  import { NullableFieldWrapper } from '../../../../admin-ui/components'
6
7
  import { entriesTyped } from '../../../../lib/core/utils'
7
8
  import type {
9
+ CellComponent,
8
10
  FieldController,
9
11
  FieldControllerConfig,
10
12
  FieldProps,
@@ -44,7 +46,14 @@ export function Field(props: FieldProps<typeof controller>) {
44
46
  label={field.label}
45
47
  errorMessage={
46
48
  !!validationMessages.length && (shouldShowErrors || forceValidation)
47
- ? validationMessages.join('. ')
49
+ ? validationMessages.length === 1
50
+ ? validationMessages[0]
51
+ : validationMessages.map((message, i) => (
52
+ <Text key={i}>
53
+ {i > 0 && <br />}
54
+ {message}
55
+ </Text>
56
+ ))
48
57
  : undefined
49
58
  }
50
59
  isDisabled={isNull}
@@ -71,6 +80,10 @@ export function Field(props: FieldProps<typeof controller>) {
71
80
  )
72
81
  }
73
82
 
83
+ export const Cell: CellComponent<typeof controller> = ({ value }) => {
84
+ return value ? <Text truncate>{value}</Text> : null
85
+ }
86
+
74
87
  type Config = FieldControllerConfig<TextFieldMeta>
75
88
 
76
89
  function validate(value: TextValue, isRequired: boolean, fieldLabel: string): string[] {
@@ -49,12 +49,13 @@ export function Field(props: FieldProps<typeof controller>) {
49
49
  description={field.description}
50
50
  isDisabled={!parsedValue}
51
51
  isReadOnly
52
+ placeholder="yyyy-mm-dd"
52
53
  value={
53
54
  parsedValue
54
55
  ? isReadonlyUTC
55
56
  ? parsedValue.toString()
56
57
  : dateFormatter.format(parsedValue.toDate(getLocalTimeZone()))
57
- : 'yyyy-mm-dd'
58
+ : ''
58
59
  }
59
60
  />
60
61
  {!!parsedValue && (