nitro-web 0.0.73 → 0.0.74

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/client/app.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import { createBrowserRouter, createHashRouter, redirect, useParams, RouterProvider } from 'react-router-dom'
1
+ import { createBrowserRouter, createHashRouter, redirect, RouterProvider } from 'react-router-dom'
2
2
  import { Fragment, ReactNode } from 'react'
3
3
  import ReactDOM from 'react-dom/client'
4
4
  import { axios, camelCase, pick, toArray, setTimeoutPromise } from 'nitro-web/util'
@@ -25,7 +25,7 @@ type Settings = {
25
25
  }
26
26
 
27
27
  type Route = {
28
- component: React.FC<{ route?: Route; params?: object; location?: object; config?: Config }>
28
+ component: React.FC<{ route?: Route; config?: Config }>
29
29
  middleware: string[]
30
30
  name: string
31
31
  path: string
@@ -70,9 +70,7 @@ export function updateJwt(token?: string | null) {
70
70
  }
71
71
 
72
72
  function App({ settings, config, storeContainer }: { settings: Settings, config: Config, storeContainer: StoreContainer }): ReactNode {
73
- // const themeNormalised = theme
74
- const router = getRouter({ settings, config })
75
- // const theme = pick(themeNormalised, []) // e.g. 'topPanelHeight'
73
+ const router = useMemo(() => getRouter({ settings, config }), [])
76
74
 
77
75
  useEffect(() => {
78
76
  /**
@@ -97,10 +95,8 @@ function App({ settings, config, storeContainer }: { settings: Settings, config:
97
95
 
98
96
  return (
99
97
  <storeContainer.Provider>
100
- {/* <ThemeProvider theme={themeNormalised}> */}
101
- { router && <RouterProvider router={router} /> }
98
+ { router && <RouterProvider router={router}/> }
102
99
  <AfterApp settings={settings} />
103
- {/* </ThemeProvider> */}
104
100
  </storeContainer.Provider>
105
101
  )
106
102
  }
@@ -206,7 +202,7 @@ function getRouter({ settings, config }: { settings: Settings, config: Config })
206
202
  ),
207
203
  path: route.path,
208
204
  loader: async () => { // request
209
- // wait for container/exposedStoreData to be setup
205
+ // wait for container/exposedStoreData to be setup (note that this causes ReactRouter to re-render, but not the page)
210
206
  if (!nonce) {
211
207
  nonce = true
212
208
  await setTimeoutPromise(() => {}, 0)
@@ -255,11 +251,9 @@ function RestoreScroll() {
255
251
 
256
252
  function RouteComponent({ route, config }: { route: Route, config: Config }) {
257
253
  const Component = route.component
258
- const params = useParams()
259
- const location = useLocation()
260
254
  document.title = route.meta?.title || ''
261
255
  return (
262
- <Component route={route} params={params} location={location} config={config} />
256
+ <Component route={route} config={config} />
263
257
  )
264
258
  }
265
259
 
@@ -14,14 +14,14 @@ type DropdownProps = {
14
14
  options?: { label: string|React.ReactNode, onClick?: Function, isSelected?: boolean, icon?: React.ReactNode, className?: string }[]
15
15
  /** Whether the dropdown is hoverable **/
16
16
  isHoverable?: boolean
17
- /** The minimum width of the menu **/
18
- minWidth?: number | string
19
17
  /** The content to render inside the top of the dropdown **/
20
18
  menuContent?: React.ReactNode
21
19
  menuClassName?: string
22
20
  menuOptionClassName?: string
23
21
  menuIsOpen?: boolean
24
22
  menuToggles?: boolean
23
+ /** The minimum width of the menu **/
24
+ minWidth?: number | string
25
25
  toggleCallback?: (isActive: boolean) => void
26
26
  }
27
27
 
@@ -30,15 +30,15 @@ export const Dropdown = forwardRef(function Dropdown({
30
30
  animate=true,
31
31
  children,
32
32
  className,
33
- dir,
33
+ dir='bottom-left',
34
34
  options,
35
35
  isHoverable,
36
- minWidth, // remove in favour of menuClassName
37
36
  menuClassName,
38
37
  menuOptionClassName,
39
38
  menuContent,
40
39
  menuIsOpen,
41
40
  menuToggles=true,
41
+ minWidth,
42
42
  toggleCallback,
43
43
  }: DropdownProps, ref) {
44
44
  // https://letsbuildui.dev/articles/building-a-dropdown-menu-component-with-react-hooks
@@ -46,6 +46,8 @@ export const Dropdown = forwardRef(function Dropdown({
46
46
  const dropdownRef = useRef<HTMLDivElement|null>(null)
47
47
  const [isActive, setIsActive] = useState(!!menuIsOpen)
48
48
  const menuStyle = getSelectStyle({ name: 'menu' })
49
+ const [direction, setDirection] = useState<null | 'bottom-left' | 'bottom-right' | 'top-left' | 'top-right'>(null)
50
+ const [ready, setReady] = useState(false)
49
51
 
50
52
  // Expose the setIsActive function to the parent component
51
53
  useImperativeHandle(ref, () => ({ setIsActive }))
@@ -76,7 +78,50 @@ export const Dropdown = forwardRef(function Dropdown({
76
78
  useEffect(() => {
77
79
  if (toggleCallback) toggleCallback(isActive)
78
80
  }, [isActive])
81
+
82
+ useEffect(() => {
83
+ setReady(false)
84
+ if (!isActive || !dropdownRef.current) return
85
+
86
+ const ul = dropdownRef.current.querySelector('ul') as HTMLElement
87
+ if (!ul) return
88
+
89
+ // Temporarily show the ul for measurement
90
+ const originalMaxHeight = ul.style.maxHeight
91
+ const originalVisibility = ul.style.visibility
92
+ const originalOpacity = ul.style.opacity
93
+ const originalPointerEvents = ul.style.pointerEvents
94
+
95
+ ul.style.maxHeight = 'none'
96
+ ul.style.visibility = 'hidden'
97
+ ul.style.opacity = '0'
98
+ ul.style.pointerEvents = 'none'
79
99
 
100
+ const dropdownHeight = ul.getBoundingClientRect().height
101
+
102
+ // Revert styles
103
+ ul.style.maxHeight = originalMaxHeight
104
+ ul.style.visibility = originalVisibility
105
+ ul.style.opacity = originalOpacity
106
+ ul.style.pointerEvents = originalPointerEvents
107
+
108
+ const rect = dropdownRef.current.getBoundingClientRect()
109
+ const spaceBelow = window.innerHeight - rect.bottom
110
+ const spaceAbove = rect.top
111
+
112
+ const side = dir.endsWith('right') ? 'right' : 'left'
113
+
114
+ const newDirection = dir.startsWith('bottom')
115
+ ? `${spaceBelow < dropdownHeight && spaceAbove > dropdownHeight ? 'top' : 'bottom'}-${side}`
116
+ : `${spaceAbove < dropdownHeight && spaceBelow > dropdownHeight ? 'bottom' : 'top'}-${side}`
117
+
118
+ setDirection(newDirection as 'bottom-left' | 'bottom-right' | 'top-left' | 'top-right')
119
+
120
+ requestAnimationFrame(() => {
121
+ setReady(true)
122
+ })
123
+ }, [isActive, dir])
124
+
80
125
  function onMouseDown(e: { key: string, preventDefault: Function }) {
81
126
  if (e.key && e.key != 'Enter') return
82
127
  if (e.key) e.preventDefault() // for button, stops buttons firing twice
@@ -87,12 +132,13 @@ export const Dropdown = forwardRef(function Dropdown({
87
132
  if (option.onClick) option.onClick(e)
88
133
  if (!menuIsOpen) setIsActive(!isActive)
89
134
  }
135
+ var ready2
90
136
 
91
137
  return (
92
138
  <div
93
139
  class={
94
- 'relative' +
95
- (dir ? ` is-${dir}` : ' is-bottom-left') +
140
+ `relative is-${direction || dir}` + // until hovered, show the original direction to prevent scrollbars
141
+ (ready2 ? ' is-ready' : '') +
96
142
  (isHoverable ? ' is-hoverable' : '') +
97
143
  (isActive ? ' is-active' : '') +
98
144
  (!animate ? ' no-animation' : '') +
@@ -113,7 +159,8 @@ export const Dropdown = forwardRef(function Dropdown({
113
159
  }
114
160
  <ul
115
161
  style={{ minWidth }}
116
- class={twMerge(`${menuStyle} absolute invisible opacity-0 select-none min-w-full z-[1] ${menuClassName}`)}
162
+ class={
163
+ twMerge(`${menuStyle} ${ready ? 'is-ready' : ''} absolute invisible opacity-0 select-none min-w-full z-[1] ${menuClassName||''}`)}
117
164
  >
118
165
  {menuContent}
119
166
  {
@@ -172,7 +219,7 @@ const style = css`
172
219
  &>ul>li:hover,
173
220
  &>ul>li:focus,
174
221
  &>ul>li.is-active {
175
- &>ul {
222
+ &>ul.is-ready {
176
223
  opacity: 1;
177
224
  visibility: visible;
178
225
  transition: transform 0.15s ease, opacity 0.15s ease;
@@ -1,7 +1,7 @@
1
1
  import GithubIcon from 'nitro-web/client/imgs/github.svg'
2
2
 
3
3
  export function GithubLink({ filename }: { filename: string }) {
4
- const base = 'https://github.com/boycce/nitro-web/blob/master/'
4
+ const base = 'https://github.com/boycce/nitro-web/blob/master/packages/'
5
5
  // Filenames are relative to the webpack start directory
6
6
  // 1. Remove ../ from filename (i.e. for _example build)
7
7
  // 2. Remove node_modules/nitro-web/ from filename (i.e. for packages using nitro-web)
@@ -1,121 +1,146 @@
1
- import { twMerge } from 'nitro-web/util'
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ import { twMerge, deepFind, getErrorFromState } from 'nitro-web/util'
3
+ import { Errors, type Error } from 'nitro-web/types'
2
4
 
3
- type CheckboxProps = {
5
+ type CheckboxProps = React.InputHTMLAttributes<HTMLInputElement> & {
6
+ /** field name or path on state (used to match errors), e.g. 'date', 'company.email' */
4
7
  name: string
5
- /** name is applied if not provided. Used for radios */
8
+ /** name is applied if id is not provided. Used for radios */
6
9
  id?: string
7
- size?: 'md' | 'sm'
10
+ /** state object to get the value, and check errors against */
11
+ state?: { errors?: Errors, [key: string]: any }
12
+ size?: number
8
13
  subtext?: string|React.ReactNode
9
14
  text?: string|React.ReactNode
10
15
  type?: 'checkbox' | 'radio' | 'toggle'
11
- [key: string]: unknown
16
+ checkboxClassName?: string
17
+ svgClassName?: string
18
+ labelClassName?: string
12
19
  }
13
20
 
14
- export function Checkbox({ name, id, size='sm', subtext, text, type='checkbox', ...props }: CheckboxProps) {
21
+ export function Checkbox({
22
+ state, size, subtext, text, type='checkbox', className, checkboxClassName, svgClassName, labelClassName, ...props
23
+ }: CheckboxProps) {
15
24
  // Checkbox/radio/toggle component
16
- // https://tailwindui.com/components/application-ui/forms/checkboxes#component-744ed4fa65ba36b925701eb4da5c6e31
17
- if (!name) throw new Error('Checkbox requires a `name` prop')
18
- id = id || name
25
+ let value!: boolean
26
+ const error = getErrorFromState(state, props.name)
27
+ const id = props.id || props.name
19
28
 
20
- const sizeMap = {
21
- sm: {
22
- checkbox: 'size-[14px]',
23
- toggleWidth: 'w-[32px]', // 4px border + (toggleAfterSize * 2)
24
- toggleHeight: 'h-[18px]',
25
- toggleAfterSize: 'after:size-[14px]', // account for 2px border
26
- },
27
- md: {
28
- checkbox: 'size-[16px]',
29
- toggleWidth: 'w-[40px]', // 4px border + (toggleAfterSize * 2)
30
- toggleHeight: 'h-[22px]',
31
- toggleAfterSize: 'after:size-[18px]', // account for 2px border
32
- },
29
+ if (!props.name) throw new Error('Checkbox requires a `name` prop')
30
+
31
+ // Value: Input is always controlled if state is passed in
32
+ if (typeof props.checked !== 'undefined') value = props.checked
33
+ else if (typeof state == 'object') {
34
+ const v = deepFind(state, props.name) as boolean | undefined
35
+ value = v ?? false
33
36
  }
34
- const _size = sizeMap[size]
37
+
38
+ const BORDER = 2
39
+ const checkboxSize = size ?? 14
40
+ const toggleHeight = size ?? 18
41
+ const toggleWidth = toggleHeight * 2 - BORDER * 2
42
+ const toggleAfterSize = toggleHeight - BORDER * 2
35
43
 
36
44
  return (
37
- <div className={'mt-2.5 mb-6 ' + twMerge(`mt-input-before mb-input-after flex gap-3 nitro-checkbox ${props.className || ''}`)}>
38
- <div className="flex shrink-0 mt-[2px]">
39
- {
40
- type !== 'toggle'
41
- ? <div className={`group grid ${_size.checkbox} grid-cols-1`}>
42
- <input
43
- {...props}
44
- id={id}
45
- name={name}
46
- type={type}
47
- className={
48
- `${type === 'radio' ? 'rounded-full' : 'rounded'} col-start-1 row-start-1 appearance-none border border-gray-300 bg-white focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 disabled:border-gray-300 disabled:bg-gray-100 disabled:checked:bg-gray-100 forced-colors:appearance-auto ` +
49
- // Default
50
- 'checked:border-blue-600 checked:bg-blue-600 indeterminate:border-blue-600 indeterminate:bg-blue-600 focus-visible:outline-blue-600 ' +
51
- // Variable-selected color defined?
52
- 'checked:!border-variable-selected checked:!bg-variable-selected indeterminate:!border-variable-selected indeterminate:!bg-variable-selected focus-visible:!outline-variable-selected'
53
- }
54
- />
55
- <svg
56
- fill="none"
57
- viewBox="0 0 14 14"
58
- className={`pointer-events-none col-start-1 row-start-1 ${_size.checkbox} self-center justify-self-center stroke-white group-has-[:disabled]:stroke-gray-950/25`}
59
- >
60
- {
61
- type === 'radio'
62
- ? <circle
63
- // cx={(_size.checkbox.match(/\d+/)?.[0] as unknown as number) / 2}
64
- // cy={(_size.checkbox.match(/\d+/)?.[0] as unknown as number) / 2}
65
- // r={(_size.checkbox.match(/\d+/)?.[0] as unknown as number) / 6}
66
- cx={7}
67
- cy={7}
68
- r={2.5}
69
- className="fill-white opacity-0 group-has-[:checked]:opacity-100"
70
- />
71
- : <>
72
- <path
73
- d="M4 8L6 10L10 4.5"
74
- strokeWidth={2}
75
- strokeLinecap="round"
76
- strokeLinejoin="round"
77
- className="opacity-0 group-has-[:checked]:opacity-100"
78
- />
79
- <path
80
- d="M4 7H10"
81
- strokeWidth={2}
82
- strokeLinecap="round"
83
- strokeLinejoin="round"
84
- className="opacity-0 group-has-[:indeterminate]:opacity-100"
85
- />
86
- </>
87
- }
88
- </svg>
89
- </div>
90
- : <div className="group grid grid-cols-1">
91
- <input
92
- {...props}
93
- id={id}
94
- name={name}
95
- type="checkbox"
96
- class="sr-only peer"
97
- />
98
- <label
99
- for={id}
100
- className={
101
- `col-start-1 row-start-1 relative ${_size.toggleWidth} ${_size.toggleHeight} bg-gray-200 peer-focus-visible:outline-none peer-focus-visible:ring-4 rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full ${_size.toggleAfterSize} after:transition-all ` +
102
- // Default
103
- 'peer-focus-visible:ring-blue-300 peer-checked:bg-blue-600 ' +
104
- // Variable-selected color defined?
105
- 'peer-focus-visible:!ring-variable-selected peer-checked:!bg-variable-selected '
106
- // Dark mode not used yet...
107
- // 'dark:peer-focus-visible:ring-blue-800 dark:bg-gray-700 dark:border-gray-600 '
108
- }
109
- />
110
- </div>
45
+ <div
46
+ className={'mt-2.5 mb-6 ' + twMerge(`mt-input-before mb-input-after text-sm nitro-checkbox ${className}`)}
47
+ >
48
+ <div className="flex gap-3 items-baseline">
49
+ <div className="shrink-0 flex items-center">
50
+ <div className="w-0">&nbsp;</div>
51
+ <div className="group relative">
52
+ {
53
+ type !== 'toggle'
54
+ ? <>
55
+ <input
56
+ {...props}
57
+ type={type}
58
+ style={{ width: checkboxSize, height: checkboxSize }}
59
+ checked={value}
60
+ className={
61
+ twMerge(
62
+ `${type === 'radio' ? 'rounded-full' : 'rounded'} appearance-none border border-gray-300 bg-white forced-colors:appearance-auto disabled:border-gray-300 disabled:bg-gray-100 disabled:checked:bg-gray-100 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 ` +
63
+ // Variable-selected theme colors (was .*-blue-600)
64
+ 'checked:border-variable-selected checked:bg-variable-selected indeterminate:border-variable-selected indeterminate:bg-variable-selected focus-visible:outline-variable-selected ' +
65
+ // Dark mode not used yet... dark:focus-visible:outline-blue-800
66
+ checkboxClassName
67
+ )
68
+ }
69
+ />
70
+ <svg
71
+ fill="none"
72
+ viewBox="0 0 14 14"
73
+ style={{ width: checkboxSize, height: checkboxSize }}
74
+ className={twMerge('absolute top-0 left-0 pointer-events-none justify-self-center stroke-white group-has-[:disabled]:stroke-gray-950/25', svgClassName)}
75
+ >
76
+ {
77
+ type === 'radio'
78
+ ? <circle
79
+ // cx={(_size.checkbox.match(/\d+/)?.[0] as unknown as number) / 2}
80
+ // cy={(_size.checkbox.match(/\d+/)?.[0] as unknown as number) / 2}
81
+ // r={(_size.checkbox.match(/\d+/)?.[0] as unknown as number) / 6}
82
+ cx={7}
83
+ cy={7}
84
+ r={2.5}
85
+ className="fill-white opacity-0 group-has-[:checked]:opacity-100"
86
+ />
87
+ : <>
88
+ <path
89
+ d="M4 8L6 10L10 4.5"
90
+ strokeWidth={2}
91
+ strokeLinecap="round"
92
+ strokeLinejoin="round"
93
+ className="opacity-0 group-has-[:checked]:opacity-100"
94
+ />
95
+ <path
96
+ d="M4 7H10"
97
+ strokeWidth={2}
98
+ strokeLinecap="round"
99
+ strokeLinejoin="round"
100
+ className="opacity-0 group-has-[:indeterminate]:opacity-100"
101
+ />
102
+ </>
103
+ }
104
+ </svg>
105
+ </>
106
+ : <>
107
+ <input
108
+ {...props}
109
+ type="checkbox"
110
+ className="sr-only peer"
111
+ checked={value}
112
+ />
113
+ <label
114
+ for={id}
115
+ style={{ width: toggleWidth, height: toggleHeight }}
116
+ className={
117
+ twMerge(
118
+ 'block bg-gray-200 rounded-full transition-colors peer-focus-visible:outline peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 ' +
119
+ // Variable-selected theme colors (was .*-blue-600)
120
+ 'peer-checked:bg-variable-selected peer-focus-visible:outline-variable-selected ' +
121
+ labelClassName
122
+ )
123
+ }
124
+ >
125
+ <span
126
+ style={{ width: toggleAfterSize, height: toggleAfterSize }}
127
+ className={
128
+ 'absolute top-[2px] start-[2px] bg-white border-gray-300 border rounded-full transition-all group-has-[:checked]:border-white group-has-[:checked]:translate-x-full '
129
+ }
130
+ />
131
+ </label>
132
+ </>
133
+ }
134
+ </div>
135
+ </div>
136
+ {text &&
137
+ <label for={id} className="text-[length:inherit] leading-[inherit] select-none">
138
+ <span className="text-gray-900">{text}</span>
139
+ <span className="ml-2 text-gray-500">{subtext}</span>
140
+ </label>
111
141
  }
112
142
  </div>
113
- {text &&
114
- <label for={id} className="self-center text-sm select-none">
115
- <span className="text-gray-900">{text}</span>
116
- <span className="ml-2 text-gray-500">{subtext}</span>
117
- </label>
118
- }
143
+ {error && <div class="mt-1.5 text-xs text-danger-foreground nitro-error">{error.detail}</div>}
119
144
  </div>
120
145
  )
121
146
  }
@@ -1,5 +1,5 @@
1
1
  /* eslint-disable @typescript-eslint/no-explicit-any */
2
- // Maybe use fill-current tw class for lucide icons (https://github.com/lucide-icons/lucide/discussions/458)
2
+ // fill-current tw class for lucide icons (https://github.com/lucide-icons/lucide/discussions/458)
3
3
  import { css } from 'twin.macro'
4
4
  import { FieldCurrency, FieldCurrencyProps, FieldColor, FieldColorProps, FieldDate, FieldDateProps } from 'nitro-web'
5
5
  import { twMerge, getErrorFromState, deepFind } from 'nitro-web/util'
@@ -204,18 +204,21 @@ export function Styleguide({ className, elements, children }: StyleguideProps) {
204
204
  <div class="grid grid-cols-3 gap-x-6 mb-4">
205
205
  <div>
206
206
  <label for="input2">Label</label>
207
- <Checkbox name="input2" type="toggle" text="Toggle sm" subtext="some additional text here." class="!mb-0" defaultChecked />
208
- <Checkbox name="input3" type="toggle" text="Toggle md" size="md" subtext="some additional text here." />
207
+ <Checkbox name="input2" type="toggle" text="Toggle sm" subtext="some additional text here." class="!mb-0"
208
+ state={state} onChange={(e) => onChange(setState, e)} />
209
+ <Checkbox name="input3" type="toggle" text="Toggle 22px" subtext="some additional text here." size={22} />
209
210
  </div>
210
211
  <div>
211
212
  <label for="input1">Label</label>
212
- <Checkbox name="input1" type="radio" text="Radio 1" subtext="some additional text here 1." id="input1-1" class="!mb-0"
213
+ <Checkbox name="input1" type="radio" text="Radio" subtext="some additional text here 1." id="input1-1" class="!mb-0"
213
214
  defaultChecked />
214
- <Checkbox name="input1" type="radio" text="Radio 2" subtext="some additional text here 2." id="input1-2" class="!mt-0" />
215
+ <Checkbox name="input1" type="radio" text="Radio 16px" subtext="some additional text here 2." id="input1-2" size={16} />
215
216
  </div>
216
217
  <div>
217
218
  <label for="input0">Label</label>
218
- <Checkbox name="input0" type="checkbox" text="Checkbox" subtext="some additional text here." defaultChecked />
219
+ <Checkbox name="input0" type="checkbox" text="Checkbox" subtext="some additional text here." class="!mb-0" defaultChecked />
220
+ <Checkbox name="input0.1" type="checkbox" text="Checkbox 16px" size={16}
221
+ subtext="some additional text here which is a bit longer that will be line-wrap to the next line." />
219
222
  </div>
220
223
  </div>
221
224
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nitro-web",
3
- "version": "0.0.73",
3
+ "version": "0.0.74",
4
4
  "repository": "github:boycce/nitro-web",
5
5
  "homepage": "https://boycce.github.io/nitro-web/",
6
6
  "description": "Nitro is a battle-tested, modular base project to turbocharge your projects, styled using Tailwind 🚀",