@duro-app/ui 3.6.0 → 4.0.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 (45) hide show
  1. package/dist/FormContext-B7ln1Miy.js +10 -0
  2. package/dist/FormContext-B7ln1Miy.js.map +1 -0
  3. package/dist/Table-Bj5u2Q-k.js +1081 -0
  4. package/dist/Table-Bj5u2Q-k.js.map +1 -0
  5. package/dist/compiler-runtime-B2IcI8_O.js +202 -0
  6. package/dist/compiler-runtime-B2IcI8_O.js.map +1 -0
  7. package/dist/components/Input/Input.d.ts +6 -1
  8. package/dist/components/Input/Input.d.ts.map +1 -1
  9. package/dist/components/Input/Input.stories.d.ts +1 -0
  10. package/dist/components/Input/Input.stories.d.ts.map +1 -1
  11. package/dist/components/Menu/Menu.d.ts.map +1 -1
  12. package/dist/components/Menu/Menu.meta.d.ts.map +1 -1
  13. package/dist/components/Menu/Menu.stories.d.ts +1 -0
  14. package/dist/components/Menu/Menu.stories.d.ts.map +1 -1
  15. package/dist/components/Table/Table.d.ts.map +1 -1
  16. package/dist/components/Textarea/Textarea.d.ts +6 -1
  17. package/dist/components/Textarea/Textarea.d.ts.map +1 -1
  18. package/dist/components/Textarea/Textarea.stories.d.ts +1 -0
  19. package/dist/components/Textarea/Textarea.stories.d.ts.map +1 -1
  20. package/dist/form.d.ts +22 -0
  21. package/dist/form.d.ts.map +1 -0
  22. package/dist/form.js +88 -0
  23. package/dist/form.js.map +1 -0
  24. package/dist/index.d.ts +0 -1
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +2132 -2208
  27. package/dist/index.js.map +1 -1
  28. package/dist/shared/devWarnOnce.d.ts +3 -0
  29. package/dist/shared/devWarnOnce.d.ts.map +1 -0
  30. package/dist/table.js +39 -38
  31. package/dist/table.js.map +1 -1
  32. package/package.json +7 -5
  33. package/src/components/Input/Input.stories.tsx +16 -0
  34. package/src/components/Input/Input.tsx +11 -0
  35. package/src/components/Menu/Menu.meta.ts +8 -5
  36. package/src/components/Menu/Menu.stories.tsx +27 -1
  37. package/src/components/Menu/Menu.tsx +17 -0
  38. package/src/components/Table/Table.tsx +2 -13
  39. package/src/components/Textarea/Textarea.stories.tsx +16 -0
  40. package/src/components/Textarea/Textarea.tsx +11 -0
  41. package/src/form.ts +21 -0
  42. package/src/index.ts +0 -1
  43. package/src/shared/devWarnOnce.ts +15 -0
  44. package/dist/Table-B4F8XyqK.js +0 -1271
  45. package/dist/Table-B4F8XyqK.js.map +0 -1
@@ -51,6 +51,11 @@ interface InputProps {
51
51
  'aria-describedby'?: string
52
52
  onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void
53
53
  onBlur?: () => void
54
+ onFocus?: (e: React.FocusEvent<HTMLInputElement>) => void
55
+ /** Keyboard handlers — e.g. Enter to commit, Escape to cancel, arrow-key
56
+ * navigation of a list driven by this field. */
57
+ onKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void
58
+ onKeyUp?: (e: React.KeyboardEvent<HTMLInputElement>) => void
54
59
  ref?: React.Ref<HTMLInputElement>
55
60
  }
56
61
 
@@ -80,6 +85,9 @@ export function Input({
80
85
  'aria-describedby': ariaDescribedby,
81
86
  onChange,
82
87
  onBlur,
88
+ onFocus,
89
+ onKeyDown,
90
+ onKeyUp,
83
91
  ref,
84
92
  }: InputProps) {
85
93
  const ctx = useFieldContext()
@@ -129,6 +137,9 @@ export function Input({
129
137
  aria-invalid={ctx?.invalid || variant === 'error' || undefined}
130
138
  onChange={effectiveOnChange as StrictInputProps['onChange']}
131
139
  onBlur={effectiveOnBlur as StrictInputProps['onBlur']}
140
+ onFocus={onFocus as StrictInputProps['onFocus']}
141
+ onKeyDown={onKeyDown as StrictInputProps['onKeyDown']}
142
+ onKeyUp={onKeyUp as StrictInputProps['onKeyUp']}
132
143
  ref={effectiveRef as React.Ref<HTMLInputElement>}
133
144
  style={[
134
145
  styles.base,
@@ -2,7 +2,7 @@ import type {ComponentMeta} from '../component-meta'
2
2
 
3
3
  export const meta: ComponentMeta = {
4
4
  description:
5
- 'Dropdown action menu. Triggers actions (not value selection). Compound component — Root is required (throws without it).',
5
+ 'Dropdown action menu. Triggers actions (not value selection). Compound component — Root is required (throws without it). Menu.Trigger renders the button itself: give it the label (text, an Icon), never a Button.',
6
6
  whenToUse: [
7
7
  'Context menu or "more actions" dropdown',
8
8
  'Navigation links in a dropdown',
@@ -19,12 +19,15 @@ export const meta: ComponentMeta = {
19
19
  kind: 'contrast',
20
20
  relationship: 'Select picks values; Menu triggers actions',
21
21
  },
22
- {component: 'Button', kind: 'composition', relationship: 'Often used as Menu.Trigger content'},
22
+ {
23
+ component: 'Button',
24
+ kind: 'contrast',
25
+ relationship:
26
+ 'Button runs one action; Menu opens a list of them. Menu.Trigger is already a button — never put a Button inside it',
27
+ },
23
28
  ],
24
29
  example: `<Menu.Root>
25
- <Menu.Trigger>
26
- <Button variant="secondary">Actions</Button>
27
- </Menu.Trigger>
30
+ <Menu.Trigger>Actions</Menu.Trigger>
28
31
  <Menu.Popup>
29
32
  <Menu.Item onClick={() => console.log('edit')}>Edit</Menu.Item>
30
33
  <Menu.Item onClick={() => console.log('duplicate')}>Duplicate</Menu.Item>
@@ -1,6 +1,7 @@
1
1
  import type {Meta, StoryObj} from '@storybook/react'
2
- import {expect, fn} from 'storybook/test'
2
+ import {expect, fn, spyOn} from 'storybook/test'
3
3
  import {Menu} from './Menu'
4
+ import {Button} from '../Button/Button'
4
5
 
5
6
  const meta: Meta = {
6
7
  title: 'Components/Menu',
@@ -144,3 +145,28 @@ export const WithLinks: Story = {
144
145
  await expect(items[0]).toHaveAttribute('href', '#admin')
145
146
  },
146
147
  }
148
+
149
+ // Misuse on purpose, so it runs as a test but stays out of the sidebar:
150
+ // Menu.Trigger is the button, and a Button inside it earns a dev warning.
151
+ export const NestedControlWarns: Story = {
152
+ tags: ['!dev', '!autodocs'],
153
+ beforeEach: () => {
154
+ const warn = spyOn(console, 'warn').mockImplementation(() => {})
155
+ return () => warn.mockRestore()
156
+ },
157
+ render: () => (
158
+ <Menu.Root>
159
+ <Menu.Trigger>
160
+ <Button variant="secondary">Nested</Button>
161
+ </Menu.Trigger>
162
+ <Menu.Popup>
163
+ <Menu.Item>Edit</Menu.Item>
164
+ </Menu.Popup>
165
+ </Menu.Root>
166
+ ),
167
+ play: async () => {
168
+ await expect(console.warn).toHaveBeenCalledWith(
169
+ expect.stringContaining('Menu.Trigger is itself the button'),
170
+ )
171
+ },
172
+ }
@@ -3,6 +3,7 @@ import {html} from 'react-strict-dom'
3
3
  import {styles} from './styles.css'
4
4
  import {MenuContext, useMenu} from './MenuContext'
5
5
  import {useMenuRoot} from './useMenuRoot'
6
+ import {devWarnOnce} from '../../shared/devWarnOnce'
6
7
 
7
8
  // --- Root ---
8
9
  interface RootProps {
@@ -22,6 +23,11 @@ function Root({children}: RootProps) {
22
23
  }
23
24
 
24
25
  // --- Trigger ---
26
+ // Trigger IS the button: aria-haspopup / aria-expanded / aria-controls have to
27
+ // sit on the focusable element, so it cannot be a wrapper like Dialog.Trigger.
28
+ // Its children are the label (text, an Icon) — never a Button or other control.
29
+ const NESTED_CONTROL = 'button, a[href], input, select, textarea, [role="button"]'
30
+
25
31
  function Trigger({children}: {children: ReactNode}) {
26
32
  const {open, toggle, menuId, triggerRef} = useMenu()
27
33
  const localRef = useRef<HTMLButtonElement>(null)
@@ -31,6 +37,17 @@ function Trigger({children}: {children: ReactNode}) {
31
37
  triggerRef.current = localRef.current
32
38
  })
33
39
 
40
+ // TypeScript can't stop a <Button> child; the DOM can tell.
41
+ useEffect(() => {
42
+ if (localRef.current?.querySelector(NESTED_CONTROL)) {
43
+ devWarnOnce(
44
+ 'Menu',
45
+ 'trigger-nested-control',
46
+ 'Menu.Trigger is itself the button — give it the label (text, an Icon), not a Button, link or other control. A control inside a <button> is invalid HTML: it breaks hydration and hands assistive tech a button inside a button.',
47
+ )
48
+ }
49
+ }, [])
50
+
34
51
  return (
35
52
  <html.button
36
53
  ref={localRef}
@@ -12,6 +12,7 @@ import {
12
12
  } from 'react'
13
13
  import {html} from 'react-strict-dom'
14
14
  import {styles} from './styles.css'
15
+ import {devWarnOnce} from '../../shared/devWarnOnce'
15
16
 
16
17
  // --- Types ---
17
18
 
@@ -43,19 +44,6 @@ function useTable() {
43
44
 
44
45
  const HeaderContext = createContext(false)
45
46
 
46
- // --- Dev-only warn registry ---
47
- //
48
- // One-shot per warning code per process: long-running dev sessions
49
- // shouldn't drown the console on every re-render of a misconfigured table.
50
- const _IS_PROD = typeof process !== 'undefined' && process.env?.NODE_ENV === 'production'
51
- const _devWarned = new Set<string>()
52
- function devWarnOnce(code: string, message: string) {
53
- if (_IS_PROD || _devWarned.has(code)) return
54
- _devWarned.add(code)
55
-
56
- console.warn(`[duro-app/ui Table] ${message}`)
57
- }
58
-
59
47
  // --- Container — owns the @container query target ---
60
48
  //
61
49
  // @deprecated Root sets up its own container query and accepts `sortChip`
@@ -148,6 +136,7 @@ function extractColumnMeta(
148
136
  // Children are JSX with no string content — stack mode will render
149
137
  // an unlabeled cell. Tell the developer once.
150
138
  devWarnOnce(
139
+ 'Table',
151
140
  'headerCell-missing-label',
152
141
  'Table.HeaderCell with JSX children must set `label` for stack-mode rendering.',
153
142
  )
@@ -36,6 +36,22 @@ export const Default: Story = {
36
36
  },
37
37
  }
38
38
 
39
+ export const KeyboardEvents: Story = {
40
+ args: {placeholder: 'Edit note', onFocus: fn(), onKeyDown: fn(), onKeyUp: fn()},
41
+ play: async ({args, canvas, userEvent}) => {
42
+ const textarea = canvas.getByPlaceholderText('Edit note')
43
+ await userEvent.click(textarea)
44
+ await expect(args.onFocus).toHaveBeenCalledTimes(1)
45
+
46
+ await userEvent.keyboard('{ArrowDown}{Enter}{Escape}')
47
+ const keys = (args.onKeyDown as ReturnType<typeof fn>).mock.calls.map(
48
+ ([e]) => (e as React.KeyboardEvent).key,
49
+ )
50
+ await expect(keys).toEqual(['ArrowDown', 'Enter', 'Escape'])
51
+ await expect(args.onKeyUp).toHaveBeenCalledTimes(3)
52
+ },
53
+ }
54
+
39
55
  export const Error: Story = {
40
56
  args: {variant: 'error', placeholder: 'Invalid content'},
41
57
  play: async ({canvas}) => {
@@ -18,6 +18,11 @@ interface TextareaProps {
18
18
  autoFocus?: boolean
19
19
  onChange?: (e: React.ChangeEvent<HTMLTextAreaElement>) => void
20
20
  onBlur?: () => void
21
+ onFocus?: (e: React.FocusEvent<HTMLTextAreaElement>) => void
22
+ /** Keyboard handlers — e.g. Enter to commit, Escape to cancel, arrow-key
23
+ * navigation of a list driven by this field. */
24
+ onKeyDown?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void
25
+ onKeyUp?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void
21
26
  ref?: React.Ref<HTMLTextAreaElement>
22
27
  }
23
28
 
@@ -33,6 +38,9 @@ export function Textarea({
33
38
  autoFocus,
34
39
  onChange,
35
40
  onBlur,
41
+ onFocus,
42
+ onKeyDown,
43
+ onKeyUp,
36
44
  ref,
37
45
  }: TextareaProps) {
38
46
  const ctx = useFieldContext()
@@ -65,6 +73,9 @@ export function Textarea({
65
73
  aria-invalid={ctx?.invalid || variant === 'error' || undefined}
66
74
  onChange={effectiveOnChange as StrictTextareaProps['onChange']}
67
75
  onBlur={effectiveOnBlur as StrictTextareaProps['onBlur']}
76
+ onFocus={onFocus as StrictTextareaProps['onFocus']}
77
+ onKeyDown={onKeyDown as StrictTextareaProps['onKeyDown']}
78
+ onKeyUp={onKeyUp as StrictTextareaProps['onKeyUp']}
68
79
  ref={effectiveRef as React.Ref<HTMLTextAreaElement>}
69
80
  style={[styles.base, styles[effectiveVariant]]}
70
81
  {...extraProps}
package/src/form.ts ADDED
@@ -0,0 +1,21 @@
1
+ /**
2
+ * `@duro-app/ui/form` — Form, the schema-validated wrapper.
3
+ *
4
+ * Form resolves an Effect Schema through `@hookform/resolvers/effect-ts`, and
5
+ * both `@hookform/resolvers` and `effect` are peer dependencies an app only
6
+ * has if it validates with them. Keeping Form out of the package root is what
7
+ * makes those peers genuinely optional: a bundler must RESOLVE every static
8
+ * import before tree-shaking can drop the code behind it, so a single
9
+ * `import ... from '@hookform/resolvers/effect-ts'` in the root's graph fails
10
+ * the build of an app that never renders a Form. (Same reasoning as
11
+ * `@duro-app/ui/table` for TanStack.)
12
+ *
13
+ * -import {Form, Field, Input} from '@duro-app/ui'
14
+ * +import {Field, Input} from '@duro-app/ui'
15
+ * +import {Form} from '@duro-app/ui/form'
16
+ *
17
+ * Field, Input and the rest stay on the root. They bind to a surrounding
18
+ * Form through react-hook-form's context, which is why `react-hook-form`
19
+ * itself is a required peer.
20
+ */
21
+ export {Form, type FormProps} from './components/Form/Form'
package/src/index.ts CHANGED
@@ -34,7 +34,6 @@ export {EmptyState} from './components/EmptyState/EmptyState'
34
34
  export {Heading, type HeadingVariant} from './components/Heading/Heading'
35
35
  export {List, type ListSelectionMode} from './components/List/List'
36
36
  export {Field} from './components/Field/Field'
37
- export {Form, type FormProps} from './components/Form/Form'
38
37
  export type {LabelPosition, NecessityIndicator} from './components/Form/FormContext'
39
38
  export {Fieldset, type FieldsetGap} from './components/Fieldset/Fieldset'
40
39
  export {Input, type InputVariant} from './components/Input/Input'
@@ -0,0 +1,15 @@
1
+ // --- Dev-only warn registry ---
2
+ //
3
+ // One-shot per warning code per process: long-running dev sessions
4
+ // shouldn't drown the console on every re-render of a misused component.
5
+ const IS_PROD = typeof process !== 'undefined' && process.env?.NODE_ENV === 'production'
6
+ const warned = new Set<string>()
7
+
8
+ /** Warn once, outside production, as `[duro-app/ui <scope>] <message>`. */
9
+ export function devWarnOnce(scope: string, code: string, message: string): void {
10
+ const key = `${scope}:${code}`
11
+ if (IS_PROD || warned.has(key)) return
12
+ warned.add(key)
13
+
14
+ console.warn(`[duro-app/ui ${scope}] ${message}`)
15
+ }