@duro-app/ui 5.26.0 → 5.28.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 (62) hide show
  1. package/dist/components/CodeBlock/CodeBlock.d.ts +1 -1
  2. package/dist/components/CodeBlock/CodeBlock.d.ts.map +1 -1
  3. package/dist/components/CodeBlock/CodeBlock.meta.d.ts.map +1 -1
  4. package/dist/components/CodeBlock/CodeBlock.stories.d.ts +8 -0
  5. package/dist/components/CodeBlock/CodeBlock.stories.d.ts.map +1 -1
  6. package/dist/components/CodeBlock/DiffRows.d.ts +15 -0
  7. package/dist/components/CodeBlock/DiffRows.d.ts.map +1 -0
  8. package/dist/components/CodeBlock/diffRoles.d.ts +18 -0
  9. package/dist/components/CodeBlock/diffRoles.d.ts.map +1 -1
  10. package/dist/components/CodeBlock/styles.css.d.ts +43 -0
  11. package/dist/components/CodeBlock/styles.css.d.ts.map +1 -1
  12. package/dist/components/CodeBlock/types.d.ts +22 -0
  13. package/dist/components/CodeBlock/types.d.ts.map +1 -1
  14. package/dist/components/Select/Select.d.ts +6 -2
  15. package/dist/components/Select/Select.d.ts.map +1 -1
  16. package/dist/components/Select/Select.stories.d.ts +6 -0
  17. package/dist/components/Select/Select.stories.d.ts.map +1 -1
  18. package/dist/components/Select/SelectContext.d.ts +1 -1
  19. package/dist/components/Select/SelectContext.d.ts.map +1 -1
  20. package/dist/components/Select/selectState.d.ts +7 -1
  21. package/dist/components/Select/selectState.d.ts.map +1 -1
  22. package/dist/components/Select/styles.css.d.ts +4 -0
  23. package/dist/components/Select/styles.css.d.ts.map +1 -1
  24. package/dist/components/Select/useSelectRoot.d.ts.map +1 -1
  25. package/dist/components/Textarea/Textarea.d.ts +7 -1
  26. package/dist/components/Textarea/Textarea.d.ts.map +1 -1
  27. package/dist/components/Textarea/Textarea.stories.d.ts +6 -0
  28. package/dist/components/Textarea/Textarea.stories.d.ts.map +1 -1
  29. package/dist/components/Textarea/styles.css.d.ts +3 -0
  30. package/dist/components/Textarea/styles.css.d.ts.map +1 -1
  31. package/dist/components/Textarea/useFileDrop.d.ts +9 -0
  32. package/dist/components/Textarea/useFileDrop.d.ts.map +1 -0
  33. package/dist/index.css +1 -1
  34. package/dist/index.d.ts +1 -0
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +3266 -3090
  37. package/dist/index.js.map +1 -1
  38. package/package.json +2 -2
  39. package/src/components/CodeBlock/CodeBlock.meta.ts +2 -1
  40. package/src/components/CodeBlock/CodeBlock.native.tsx +12 -1
  41. package/src/components/CodeBlock/CodeBlock.stories.tsx +98 -0
  42. package/src/components/CodeBlock/CodeBlock.tsx +28 -10
  43. package/src/components/CodeBlock/DiffRows.tsx +101 -0
  44. package/src/components/CodeBlock/diffRoles.ts +42 -0
  45. package/src/components/CodeBlock/styles.css.ts +41 -0
  46. package/src/components/CodeBlock/types.ts +24 -0
  47. package/src/components/Select/Select.meta.ts +1 -1
  48. package/src/components/Select/Select.native.tsx +15 -4
  49. package/src/components/Select/Select.stories.tsx +52 -0
  50. package/src/components/Select/Select.tsx +27 -6
  51. package/src/components/Select/SelectContext.ts +1 -1
  52. package/src/components/Select/selectState.ts +17 -1
  53. package/src/components/Select/styles.css.ts +4 -0
  54. package/src/components/Select/useSelectRoot.ts +32 -22
  55. package/src/components/Textarea/Textarea.meta.ts +1 -1
  56. package/src/components/Textarea/Textarea.stories.tsx +41 -0
  57. package/src/components/Textarea/Textarea.tsx +21 -0
  58. package/src/components/Textarea/styles.css.ts +3 -0
  59. package/src/components/Textarea/useFileDrop.native.ts +6 -0
  60. package/src/components/Textarea/useFileDrop.ts +46 -0
  61. package/src/index.native.ts +1 -0
  62. package/src/index.ts +1 -0
@@ -11,7 +11,7 @@ export interface SelectContextValue {
11
11
  listboxId: string
12
12
  highlightedId: string | null
13
13
  setHighlightedId: (id: string | null) => void
14
- registerItem: (id: string, value: string, element: HTMLElement) => () => void
14
+ registerItem: (id: string, value: string, element: HTMLElement, disabled?: boolean) => () => void
15
15
  triggerRef: React.RefObject<HTMLButtonElement | null>
16
16
  }
17
17
 
@@ -1,4 +1,4 @@
1
- import {useCallback, useState} from 'react'
1
+ import {useCallback, useEffect, useRef, useState} from 'react'
2
2
  import {useControllableValue} from '../../hooks/useControllableValue'
3
3
 
4
4
  export interface SelectStateOptions {
@@ -6,6 +6,12 @@ export interface SelectStateOptions {
6
6
  value?: string
7
7
  onValueChange?: (value: string | null) => void
8
8
  initialLabels?: Record<string, string>
9
+ /**
10
+ * Called when the list opens or closes, by any means (a press, a key, a pick,
11
+ * Escape, a press outside): for work that should start only once someone is
12
+ * choosing, such as checking which options are available.
13
+ */
14
+ onOpenChange?: (open: boolean) => void
9
15
  }
10
16
 
11
17
  /**
@@ -19,6 +25,7 @@ export function useSelectState({
19
25
  value: controlledValue,
20
26
  onValueChange,
21
27
  initialLabels,
28
+ onOpenChange,
22
29
  }: SelectStateOptions) {
23
30
  const [value, setValue] = useControllableValue<string | null>(
24
31
  controlledValue,
@@ -26,6 +33,15 @@ export function useSelectState({
26
33
  onValueChange,
27
34
  )
28
35
  const [open, setOpen] = useState(false)
36
+ // Reported after the change commits, once per transition; never for the first render.
37
+ const onOpenChangeRef = useRef(onOpenChange)
38
+ onOpenChangeRef.current = onOpenChange
39
+ const reportedOpenRef = useRef(open)
40
+ useEffect(() => {
41
+ if (reportedOpenRef.current === open) return
42
+ reportedOpenRef.current = open
43
+ onOpenChangeRef.current?.(open)
44
+ }, [open])
29
45
  const [labels, setLabels] = useState<Record<string, string>>(initialLabels ?? {})
30
46
 
31
47
  const registerLabel = useCallback((v: string, label: string) => {
@@ -121,6 +121,10 @@ export const styles = css.create({
121
121
  itemHighlighted: {
122
122
  backgroundColor: colors.bgCardHover,
123
123
  },
124
+ itemDisabled: {
125
+ color: colors.textDisabled,
126
+ cursor: 'not-allowed',
127
+ },
124
128
  hidden: {
125
129
  display: 'none',
126
130
  },
@@ -12,8 +12,15 @@ export function useSelectRoot(options: SelectStateOptions) {
12
12
  const listboxId = useId()
13
13
  const rootRef = useRef<HTMLDivElement>(null)
14
14
  const triggerRef = useRef<HTMLButtonElement | null>(null)
15
- const itemsRef = useRef(new Map<string, {value: string; element: HTMLElement}>())
15
+ const itemsRef = useRef(
16
+ new Map<string, {value: string; element: HTMLElement; disabled: boolean}>(),
17
+ )
16
18
  const orderRef = useRef<string[]>([])
19
+ // What the keys move through: the options in document order, without the disabled ones.
20
+ const enabledOrder = useCallback(
21
+ () => orderRef.current.filter((id) => !itemsRef.current.get(id)?.disabled),
22
+ [],
23
+ )
17
24
 
18
25
  const close = useCallback(() => {
19
26
  setOpen(false)
@@ -25,7 +32,7 @@ export function useSelectRoot(options: SelectStateOptions) {
25
32
  setOpen((prev) => {
26
33
  if (!prev) {
27
34
  const items = itemsRef.current
28
- const order = orderRef.current
35
+ const order = enabledOrder()
29
36
  let found: string | null = null
30
37
  for (const id of order) {
31
38
  const item = items.get(id)
@@ -40,24 +47,27 @@ export function useSelectRoot(options: SelectStateOptions) {
40
47
  }
41
48
  return !prev
42
49
  })
43
- }, [value, setOpen])
50
+ }, [value, setOpen, enabledOrder])
44
51
 
45
- const registerItem = useCallback((id: string, itemValue: string, element: HTMLElement) => {
46
- itemsRef.current.set(id, {value: itemValue, element})
47
- const map = itemsRef.current
48
- const ids = [...map.keys()]
49
- ids.sort((a, b) => {
50
- const elA = map.get(a)?.element
51
- const elB = map.get(b)?.element
52
- if (!elA || !elB) return 0
53
- return elA.compareDocumentPosition(elB) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1
54
- })
55
- orderRef.current = ids
56
- return () => {
57
- itemsRef.current.delete(id)
58
- orderRef.current = orderRef.current.filter((i) => i !== id)
59
- }
60
- }, [])
52
+ const registerItem = useCallback(
53
+ (id: string, itemValue: string, element: HTMLElement, disabled = false) => {
54
+ itemsRef.current.set(id, {value: itemValue, element, disabled})
55
+ const map = itemsRef.current
56
+ const ids = [...map.keys()]
57
+ ids.sort((a, b) => {
58
+ const elA = map.get(a)?.element
59
+ const elB = map.get(b)?.element
60
+ if (!elA || !elB) return 0
61
+ return elA.compareDocumentPosition(elB) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1
62
+ })
63
+ orderRef.current = ids
64
+ return () => {
65
+ itemsRef.current.delete(id)
66
+ orderRef.current = orderRef.current.filter((i) => i !== id)
67
+ }
68
+ },
69
+ [],
70
+ )
61
71
 
62
72
  // Closed: ArrowDown/ArrowUp on the trigger open the listbox (the APG
63
73
  // select-only combobox), on the selected option or the first. In a Toolbar
@@ -82,7 +92,7 @@ export function useSelectRoot(options: SelectStateOptions) {
82
92
  if (!root || !open) return
83
93
 
84
94
  function handleKeyDown(e: KeyboardEvent) {
85
- const order = orderRef.current
95
+ const order = enabledOrder()
86
96
  const items = itemsRef.current
87
97
  if (e.key === 'Escape') {
88
98
  e.preventDefault()
@@ -124,7 +134,7 @@ export function useSelectRoot(options: SelectStateOptions) {
124
134
  setHighlightedId((prev) => {
125
135
  if (prev) {
126
136
  const item = items.get(prev)
127
- if (item) {
137
+ if (item && !item.disabled) {
128
138
  setValue(item.value)
129
139
  close()
130
140
  }
@@ -142,7 +152,7 @@ export function useSelectRoot(options: SelectStateOptions) {
142
152
 
143
153
  root.addEventListener('keydown', handleKeyDown)
144
154
  return () => root.removeEventListener('keydown', handleKeyDown)
145
- }, [open, close, setValue])
155
+ }, [open, close, setValue, enabledOrder])
146
156
 
147
157
  const ctx: SelectContextValue = useMemo(
148
158
  () => ({
@@ -2,7 +2,7 @@ import type {ComponentMeta} from '../component-meta'
2
2
 
3
3
  export const meta: ComponentMeta = {
4
4
  description:
5
- 'Multi-line text input with automatic Field/Form integration. Same Field binding behavior as Input. autoGrow makes the height follow the content from rows up to maxRows, then it scrolls (CSS field-sizing, with a scrollHeight fallback; native grows with the content and caps at maxRows). The anchor props of Listbox.getAnchorProps() (role combobox, aria-controls, aria-expanded, aria-activedescendant) are forwarded as ARIA attributes on web and as the matching accessibility props on native, so a Listbox can be anchored to it while the editor keeps focus.',
5
+ 'Multi-line text input with automatic Field/Form integration. Same Field binding behavior as Input. autoGrow makes the height follow the content from rows up to maxRows, then it scrolls (CSS field-sizing, with a scrollHeight fallback; native grows with the content and caps at maxRows). The anchor props of Listbox.getAnchorProps() (role combobox, aria-controls, aria-expanded, aria-activedescendant) are forwarded as ARIA attributes on web and as the matching accessibility props on native, so a Listbox can be anchored to it while the editor keeps focus. On the web, onFiles receives files pasted into the field or dropped on it (the drop ring shows while files are dragged over it); text pastes stay text.',
6
6
  whenToUse: [
7
7
  'Multi-line text entry (comments, messages, descriptions)',
8
8
  'A composer that grows with its text (autoGrow, maxRows)',
@@ -231,3 +231,44 @@ export const AnchoredListbox: Story = {
231
231
  await expect(editor).toHaveFocus()
232
232
  },
233
233
  }
234
+
235
+ /**
236
+ * `onFiles` (web): a file pasted into the field or dropped on it goes to
237
+ * `onFiles`; while files are dragged over it the field shows the drop ring.
238
+ * Pasted text stays text.
239
+ */
240
+ export const PasteAndDropFiles: Story = {
241
+ args: {placeholder: 'Paste or drop a screenshot', onFiles: fn()},
242
+ play: async ({canvas, args}) => {
243
+ const field = canvas.getByPlaceholderText('Paste or drop a screenshot')
244
+ const withFile = () => {
245
+ const transfer = new DataTransfer()
246
+ transfer.items.add(new File(['png'], 'shot.png', {type: 'image/png'}))
247
+ return transfer
248
+ }
249
+
250
+ field.dispatchEvent(
251
+ new ClipboardEvent('paste', {clipboardData: withFile(), bubbles: true, cancelable: true}),
252
+ )
253
+ await expect(args.onFiles).toHaveBeenCalledTimes(1)
254
+ await expect((args.onFiles as ReturnType<typeof fn>).mock.calls[0][0][0].name).toBe('shot.png')
255
+
256
+ const text = new DataTransfer()
257
+ text.setData('text/plain', 'hello')
258
+ field.dispatchEvent(
259
+ new ClipboardEvent('paste', {clipboardData: text, bubbles: true, cancelable: true}),
260
+ )
261
+ await expect(args.onFiles).toHaveBeenCalledTimes(1)
262
+
263
+ const ringBefore = getComputedStyle(field).boxShadow
264
+ field.dispatchEvent(
265
+ new DragEvent('dragenter', {dataTransfer: withFile(), bubbles: true, cancelable: true}),
266
+ )
267
+ await waitFor(() => expect(getComputedStyle(field).boxShadow).not.toBe(ringBefore))
268
+ const drop = new DragEvent('drop', {dataTransfer: withFile(), bubbles: true, cancelable: true})
269
+ field.dispatchEvent(drop)
270
+ await expect(drop.defaultPrevented).toBe(true)
271
+ await expect(args.onFiles).toHaveBeenCalledTimes(2)
272
+ await waitFor(() => expect(getComputedStyle(field).boxShadow).toBe(ringBefore))
273
+ },
274
+ }
@@ -3,6 +3,7 @@ import {html} from 'react-strict-dom'
3
3
  import {useFieldContext} from '../Field/FieldContext'
4
4
  import type {ListboxAnchorProps} from '../Listbox/Listbox'
5
5
  import {useAutoGrowFallback} from './useAutoGrowFallback'
6
+ import {useFileDrop} from './useFileDrop'
6
7
  import {grow} from './grow'
7
8
  import {styles} from './styles.css'
8
9
 
@@ -39,6 +40,12 @@ interface TextareaBaseProps {
39
40
  * navigation of a list driven by this field. */
40
41
  onKeyDown?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void
41
42
  onKeyUp?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void
43
+ /**
44
+ * Web: files pasted into the field or dropped on it (a screenshot, a log).
45
+ * The field shows the drop ring while files are dragged over it. Text
46
+ * pastes and drops stay text. Native: never called; attach with FileTrigger.
47
+ */
48
+ onFiles?: (files: readonly File[]) => void
42
49
  ref?: React.Ref<HTMLTextAreaElement>
43
50
  }
44
51
 
@@ -65,6 +72,7 @@ export function Textarea({
65
72
  onFocus,
66
73
  onKeyDown,
67
74
  onKeyUp,
75
+ onFiles,
68
76
  ref,
69
77
  }: TextareaProps) {
70
78
  const ctx = useFieldContext()
@@ -92,6 +100,17 @@ export function Textarea({
92
100
  [effectiveRef],
93
101
  )
94
102
  useAutoGrowFallback(innerRef, autoGrow ? 'content' : 'fixed')
103
+ const dropOver = useFileDrop(innerRef, onFiles)
104
+ const onPaste = onFiles
105
+ ? // Structural: the native build (no DOM lib) types this file too; only the web pastes files.
106
+ (event: {clipboardData?: {files?: ArrayLike<File>} | null; preventDefault: () => void}) => {
107
+ const files = Array.from(event.clipboardData?.files ?? [])
108
+ if (files.length === 0) return
109
+ // Files only: the field keeps no file name as text.
110
+ event.preventDefault()
111
+ onFiles(files)
112
+ }
113
+ : undefined
95
114
 
96
115
  return (
97
116
  <html.textarea
@@ -112,6 +131,7 @@ export function Textarea({
112
131
  onFocus={onFocus as StrictTextareaProps['onFocus']}
113
132
  onKeyDown={onKeyDown as StrictTextareaProps['onKeyDown']}
114
133
  onKeyUp={onKeyUp as StrictTextareaProps['onKeyUp']}
134
+ onPaste={onPaste as StrictTextareaProps['onPaste']}
115
135
  role={role}
116
136
  aria-controls={ariaControls}
117
137
  aria-expanded={ariaExpanded}
@@ -124,6 +144,7 @@ export function Textarea({
124
144
  styles[effectiveVariant],
125
145
  autoGrow && grow.autoGrow,
126
146
  autoGrow && maxRows !== undefined && grow.maxRows(maxRows),
147
+ dropOver && styles.dropOver,
127
148
  ]}
128
149
  {...extraProps}
129
150
  />
@@ -1,4 +1,5 @@
1
1
  import {css} from 'react-strict-dom'
2
+ import {shadows} from '@duro-app/tokens/tokens/shadows.css'
2
3
  import {colors} from '@duro-app/tokens/tokens/colors.css'
3
4
  import {spacing, radii} from '@duro-app/tokens/tokens/spacing.css'
4
5
  import {typography} from '@duro-app/tokens/tokens/typography.css'
@@ -56,4 +57,6 @@ export const styles = css.create({
56
57
  ':focus': colors.error,
57
58
  },
58
59
  },
60
+ // Files dragged over the field: the same ring as a DragDrop zone under the pointer.
61
+ dropOver: {boxShadow: shadows.dropOver},
59
62
  })
@@ -0,0 +1,6 @@
1
+ import type {RefObject} from 'react'
2
+
3
+ /** A phone has no drag and drop of files onto a field; attach with FileTrigger. */
4
+ export function useFileDrop(_ref: RefObject<unknown>, _onFiles: unknown): boolean {
5
+ return false
6
+ }
@@ -0,0 +1,46 @@
1
+ import {useEffect, useState, type RefObject} from 'react'
2
+
3
+ const carriesFiles = (event: DragEvent) =>
4
+ Array.from(event.dataTransfer?.types ?? []).includes('Files')
5
+
6
+ /**
7
+ * Files dropped on the field go to `onFiles`, and `over` says files are
8
+ * being dragged over it (for the drop ring). A listener on the element:
9
+ * react-strict-dom passes no drag events through. Text drags stay the
10
+ * browser's.
11
+ */
12
+ export function useFileDrop(
13
+ ref: RefObject<HTMLTextAreaElement | null>,
14
+ onFiles: ((files: readonly File[]) => void) | undefined,
15
+ ): boolean {
16
+ const [over, setOver] = useState(false)
17
+ useEffect(() => {
18
+ const field = ref.current
19
+ if (!field || !onFiles) return
20
+ const enter = (event: DragEvent) => {
21
+ if (!carriesFiles(event)) return
22
+ // Claimed, or the browser opens the file instead of dropping it here.
23
+ event.preventDefault()
24
+ setOver(true)
25
+ }
26
+ const leave = () => setOver(false)
27
+ const drop = (event: DragEvent) => {
28
+ if (!carriesFiles(event)) return
29
+ event.preventDefault()
30
+ setOver(false)
31
+ const files = Array.from(event.dataTransfer?.files ?? [])
32
+ if (files.length > 0) onFiles(files)
33
+ }
34
+ field.addEventListener('dragenter', enter)
35
+ field.addEventListener('dragover', enter)
36
+ field.addEventListener('dragleave', leave)
37
+ field.addEventListener('drop', drop)
38
+ return () => {
39
+ field.removeEventListener('dragenter', enter)
40
+ field.removeEventListener('dragover', enter)
41
+ field.removeEventListener('dragleave', leave)
42
+ field.removeEventListener('drop', drop)
43
+ }
44
+ }, [onFiles, ref])
45
+ return over
46
+ }
@@ -29,6 +29,7 @@ export {Textarea, type TextareaVariant} from './components/Textarea/Textarea'
29
29
 
30
30
  // Text and layout
31
31
  export {CodeBlock, type CodeBlockProps} from './components/CodeBlock/CodeBlock'
32
+ export type {CodeBlockLineAction, DiffLineInfo} from './components/CodeBlock/types'
32
33
  export {Prose} from './components/Prose/Prose'
33
34
  export {Toolbar, type ToolbarProps} from './components/Toolbar/Toolbar'
34
35
 
package/src/index.ts CHANGED
@@ -32,6 +32,7 @@ export {Callout, type CalloutVariant} from './components/Callout/Callout'
32
32
  export {Card, type CardVariant, type CardSize} from './components/Card/Card'
33
33
  export {Checkbox} from './components/Checkbox/Checkbox'
34
34
  export {CodeBlock} from './components/CodeBlock/CodeBlock'
35
+ export type {CodeBlockLineAction, DiffLineInfo} from './components/CodeBlock/types'
35
36
  export {CheckboxGroup} from './components/CheckboxGroup/CheckboxGroup'
36
37
  export {ColorInput} from './components/ColorInput/ColorInput'
37
38
  export {ConfirmDialog} from './components/ConfirmDialog/ConfirmDialog'