@duro-app/ui 0.26.0 → 0.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 (30) hide show
  1. package/dist/components/CheckboxGroup/CheckboxGroup.d.ts.map +1 -1
  2. package/dist/components/Input/Input.d.ts +2 -1
  3. package/dist/components/Input/Input.d.ts.map +1 -1
  4. package/dist/components/ScrollArea/ScrollArea.d.ts.map +1 -1
  5. package/dist/components/ScrollArea/ScrollAreaContext.d.ts +1 -0
  6. package/dist/components/ScrollArea/ScrollAreaContext.d.ts.map +1 -1
  7. package/dist/components/ScrollArea/useScrollAreaRoot.d.ts.map +1 -1
  8. package/dist/components/Tag/Tag.d.ts.map +1 -1
  9. package/dist/components/Tag/styles.css.d.ts +8 -6
  10. package/dist/components/Tag/styles.css.d.ts.map +1 -1
  11. package/dist/components/TagGroup/styles.css.d.ts +21 -35
  12. package/dist/components/TagGroup/styles.css.d.ts.map +1 -1
  13. package/dist/components/Textarea/Textarea.d.ts +2 -1
  14. package/dist/components/Textarea/Textarea.d.ts.map +1 -1
  15. package/dist/index.css +1 -1
  16. package/dist/index.js +1648 -1650
  17. package/dist/index.js.map +1 -1
  18. package/package.json +2 -2
  19. package/src/components/CheckboxGroup/CheckboxGroup.tsx +0 -1
  20. package/src/components/Field/styles.css.ts +1 -1
  21. package/src/components/Input/Input.tsx +8 -4
  22. package/src/components/ScrollArea/ScrollArea.tsx +27 -3
  23. package/src/components/ScrollArea/ScrollAreaContext.ts +1 -0
  24. package/src/components/ScrollArea/useScrollAreaRoot.ts +3 -1
  25. package/src/components/Tag/Tag.tsx +26 -24
  26. package/src/components/Tag/styles.css.ts +20 -15
  27. package/src/components/TagGroup/TagGroup.stories.tsx +5 -5
  28. package/src/components/TagGroup/TagGroup.tsx +1 -1
  29. package/src/components/TagGroup/styles.css.ts +34 -48
  30. package/src/components/Textarea/Textarea.tsx +6 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@duro-app/ui",
3
- "version": "0.26.0",
3
+ "version": "0.28.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -53,7 +53,7 @@
53
53
  }
54
54
  },
55
55
  "dependencies": {
56
- "@duro-app/tokens": "^0.26.0"
56
+ "@duro-app/tokens": "^0.28.0"
57
57
  },
58
58
  "devDependencies": {
59
59
  "@babel/preset-typescript": "^7.28.0",
@@ -56,7 +56,6 @@ function Root({
56
56
  <CheckboxGroupContext.Provider value={{value, onToggle, disabled}}>
57
57
  <html.div
58
58
  role="group"
59
- aria-orientation={orientation}
60
59
  style={[styles.root, orientation === 'horizontal' && styles.rootHorizontal]}
61
60
  >
62
61
  {children}
@@ -31,7 +31,7 @@ export const styles = css.create({
31
31
  },
32
32
  error: {
33
33
  fontSize: typography.fontSizeXs,
34
- color: colors.error,
34
+ color: colors.errorText,
35
35
  },
36
36
  fieldContent: {
37
37
  display: 'flex',
@@ -29,6 +29,7 @@ interface InputProps {
29
29
  value?: string
30
30
  defaultValue?: string
31
31
  disabled?: boolean
32
+ autoFocus?: boolean
32
33
  onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void
33
34
  onBlur?: () => void
34
35
  ref?: React.Ref<HTMLInputElement>
@@ -46,6 +47,7 @@ export function Input({
46
47
  value,
47
48
  defaultValue,
48
49
  disabled,
50
+ autoFocus,
49
51
  onChange,
50
52
  onBlur,
51
53
  ref,
@@ -62,9 +64,11 @@ export function Input({
62
64
  const effectiveDisabled = disabled ?? ctx?.disabled
63
65
  const effectiveVariant = ctx?.invalid ? 'error' : variant
64
66
 
65
- // react-strict-dom omits web-only `pattern` from its types, but the
66
- // underlying DOM element supports it. Type-assert to pass it through.
67
- const extraProps = pattern !== undefined ? {pattern} : undefined
67
+ // react-strict-dom omits some web-only props from its types, but the
68
+ // underlying DOM element supports them. Type-assert to pass them through.
69
+ const extraProps: Record<string, unknown> = {}
70
+ if (pattern !== undefined) extraProps.pattern = pattern
71
+ if (autoFocus) extraProps.autoFocus = true
68
72
 
69
73
  return (
70
74
  <html.input
@@ -86,7 +90,7 @@ export function Input({
86
90
  onBlur={effectiveOnBlur as StrictInputProps['onBlur']}
87
91
  ref={effectiveRef as React.Ref<HTMLInputElement>}
88
92
  style={[styles.base, styles[effectiveVariant], groupCtx?.inGroup && styles.inGroup]}
89
- {...(extraProps as Record<string, unknown>)}
93
+ {...extraProps}
90
94
  />
91
95
  )
92
96
  }
@@ -28,11 +28,15 @@ interface ViewportProps {
28
28
  }
29
29
 
30
30
  function Viewport({children, maxHeight}: ViewportProps) {
31
- const {viewportRef} = useScrollArea()
31
+ const {viewportRef, viewportId} = useScrollArea()
32
32
 
33
33
  return (
34
34
  <html.div
35
35
  ref={viewportRef}
36
+ id={viewportId}
37
+ tabIndex={0}
38
+ role="region"
39
+ aria-label="Scrollable content"
36
40
  style={[styles.viewport, maxHeight != null && styles.viewportMaxHeight(maxHeight)]}
37
41
  >
38
42
  {children}
@@ -65,7 +69,16 @@ interface ScrollbarProps {
65
69
  }
66
70
 
67
71
  function Scrollbar({orientation = 'vertical', children}: ScrollbarProps) {
68
- const {scrolling, scrollHeight, scrollWidth, clientHeight, clientWidth} = useScrollArea()
72
+ const {
73
+ viewportId,
74
+ scrolling,
75
+ scrollTop,
76
+ scrollLeft,
77
+ scrollHeight,
78
+ scrollWidth,
79
+ clientHeight,
80
+ clientWidth,
81
+ } = useScrollArea()
69
82
 
70
83
  // Hide scrollbar when content fits
71
84
  const hasOverflow =
@@ -73,11 +86,22 @@ function Scrollbar({orientation = 'vertical', children}: ScrollbarProps) {
73
86
 
74
87
  if (!hasOverflow) return null
75
88
 
89
+ const isVertical = orientation === 'vertical'
90
+ const maxScroll = isVertical ? scrollHeight - clientHeight : scrollWidth - clientWidth
91
+ const scrollOffset = isVertical ? scrollTop : scrollLeft
92
+ const valueNow = maxScroll > 0 ? Math.round((scrollOffset / maxScroll) * 100) : 0
93
+
76
94
  return (
77
95
  <html.div
96
+ role="scrollbar"
97
+ aria-controls={viewportId}
98
+ aria-orientation={orientation}
99
+ aria-valuenow={valueNow}
100
+ aria-valuemin={0}
101
+ aria-valuemax={100}
78
102
  style={[
79
103
  styles.scrollbar,
80
- orientation === 'vertical' ? styles.scrollbarVertical : styles.scrollbarHorizontal,
104
+ isVertical ? styles.scrollbarVertical : styles.scrollbarHorizontal,
81
105
  scrolling ? styles.scrollbarVisible : styles.scrollbarHidden,
82
106
  ]}
83
107
  >
@@ -3,6 +3,7 @@ import {createContext, useContext} from 'react'
3
3
  export interface ScrollAreaContextValue {
4
4
  viewportRef: React.RefObject<HTMLDivElement | null>
5
5
  contentRef: React.RefObject<HTMLDivElement | null>
6
+ viewportId: string
6
7
  scrollTop: number
7
8
  scrollLeft: number
8
9
  scrollHeight: number
@@ -1,7 +1,8 @@
1
- import {useState, useCallback, useRef, useEffect} from 'react'
1
+ import {useState, useCallback, useRef, useEffect, useId} from 'react'
2
2
  import type {ScrollAreaContextValue} from './ScrollAreaContext'
3
3
 
4
4
  export function useScrollAreaRoot(): ScrollAreaContextValue {
5
+ const viewportId = useId()
5
6
  const viewportRef = useRef<HTMLDivElement | null>(null)
6
7
  const contentRef = useRef<HTMLDivElement | null>(null)
7
8
  const [scrollTop, setScrollTop] = useState(0)
@@ -61,6 +62,7 @@ export function useScrollAreaRoot(): ScrollAreaContextValue {
61
62
  return {
62
63
  viewportRef,
63
64
  contentRef,
65
+ viewportId,
64
66
  scrollTop,
65
67
  scrollLeft,
66
68
  scrollHeight,
@@ -91,31 +91,33 @@ export function Tag({
91
91
  {children}
92
92
  </html.span>
93
93
  {removable && (
94
- <html.button
95
- type="button"
96
- aria-label={removeLabel}
97
- disabled={disabled}
98
- onClick={(e) => {
99
- ;(e as any).stopPropagation?.()
100
- onRemove()
101
- }}
102
- tabIndex={-1}
103
- style={[styles.removeButton, disabled && styles.removeButtonDisabled]}
104
- >
105
- <svg
106
- width={size === 'sm' ? 10 : 12}
107
- height={size === 'sm' ? 10 : 12}
108
- viewBox="0 0 12 12"
109
- fill="none"
94
+ <html.span {...(isGrouped ? {role: 'gridcell' as any} : {})}>
95
+ <html.button
96
+ type="button"
97
+ aria-label={removeLabel}
98
+ disabled={disabled}
99
+ onClick={(e) => {
100
+ ;(e as any).stopPropagation?.()
101
+ onRemove()
102
+ }}
103
+ tabIndex={-1}
104
+ style={[styles.removeButton, disabled && styles.removeButtonDisabled]}
110
105
  >
111
- <path
112
- d="M3 3l6 6M9 3l-6 6"
113
- stroke="currentColor"
114
- strokeWidth={1.5}
115
- strokeLinecap="round"
116
- />
117
- </svg>
118
- </html.button>
106
+ <svg
107
+ width={size === 'sm' ? 10 : 12}
108
+ height={size === 'sm' ? 10 : 12}
109
+ viewBox="0 0 12 12"
110
+ fill="none"
111
+ >
112
+ <path
113
+ d="M3 3l6 6M9 3l-6 6"
114
+ stroke="currentColor"
115
+ strokeWidth={1.5}
116
+ strokeLinecap="round"
117
+ />
118
+ </svg>
119
+ </html.button>
120
+ </html.span>
119
121
  )}
120
122
  </>
121
123
  )
@@ -7,13 +7,15 @@ export const styles = css.create({
7
7
  base: {
8
8
  display: 'inline-flex',
9
9
  alignItems: 'center',
10
- gap: spacing.xs,
10
+ gap: spacing.sm,
11
11
  fontFamily: typography.fontFamily,
12
- fontWeight: typography.fontWeightMedium,
12
+ fontWeight: typography.fontWeightNormal,
13
13
  lineHeight: 1,
14
- borderRadius: radii.full,
14
+ borderRadius: 4,
15
15
  whiteSpace: 'nowrap',
16
- borderWidth: 0,
16
+ borderWidth: 1,
17
+ borderStyle: 'solid',
18
+ borderColor: colors.border,
17
19
  outlineWidth: {
18
20
  default: 0,
19
21
  ':focus-visible': 2,
@@ -32,29 +34,32 @@ export const styles = css.create({
32
34
  },
33
35
  },
34
36
  sizeMd: {
37
+ paddingTop: spacing.sm,
38
+ paddingBottom: spacing.sm,
39
+ paddingLeft: spacing.ms,
40
+ paddingRight: spacing.ms,
41
+ fontSize: typography.fontSizeSm,
42
+ },
43
+ sizeSm: {
35
44
  paddingTop: spacing.xs,
36
45
  paddingBottom: spacing.xs,
37
46
  paddingLeft: spacing.sm,
38
47
  paddingRight: spacing.sm,
39
48
  fontSize: typography.fontSizeXs,
40
49
  },
41
- sizeSm: {
42
- paddingTop: 2,
43
- paddingBottom: 2,
44
- paddingLeft: spacing.xs,
45
- paddingRight: spacing.xs,
46
- fontSize: '0.625rem',
47
- },
48
50
  // Reduce right padding when removable to keep visual balance with the button
49
51
  removableMd: {
50
- paddingRight: spacing.xs,
52
+ paddingRight: spacing.sm,
51
53
  },
52
54
  removableSm: {
53
- paddingRight: 2,
55
+ paddingRight: spacing.xs,
54
56
  },
55
57
  default: {
56
- backgroundColor: colors.bgCardHover,
57
- color: colors.textMuted,
58
+ backgroundColor: {
59
+ default: colors.bg,
60
+ ':hover': colors.bgCardHover,
61
+ },
62
+ color: colors.text,
58
63
  },
59
64
  success: {
60
65
  backgroundColor: colors.successBg,
@@ -44,6 +44,7 @@ function EditableExample() {
44
44
 
45
45
  return (
46
46
  <TagGroup.Root value={tags} onValueChange={setTags}>
47
+ <TagGroup.Input placeholder="Add tag..." />
47
48
  <TagGroup.List aria-label="Tags">
48
49
  {tags.map((t) => (
49
50
  <Tag key={t} value={t}>
@@ -51,7 +52,6 @@ function EditableExample() {
51
52
  </Tag>
52
53
  ))}
53
54
  </TagGroup.List>
54
- <TagGroup.Input placeholder="Add tag..." />
55
55
  </TagGroup.Root>
56
56
  )
57
57
  }
@@ -76,6 +76,7 @@ export const WithDefaultValue: Story = {
76
76
  const [tags, setTags] = useState(['one', 'two'])
77
77
  return (
78
78
  <TagGroup.Root defaultValue={['one', 'two']} onValueChange={setTags}>
79
+ <TagGroup.Input placeholder="Add..." />
79
80
  <TagGroup.List aria-label="Items">
80
81
  {tags.map((t) => (
81
82
  <Tag key={t} value={t}>
@@ -83,7 +84,6 @@ export const WithDefaultValue: Story = {
83
84
  </Tag>
84
85
  ))}
85
86
  </TagGroup.List>
86
- <TagGroup.Input placeholder="Add..." />
87
87
  </TagGroup.Root>
88
88
  )
89
89
  }
@@ -102,12 +102,14 @@ function EmailExample() {
102
102
  <Stack gap="md">
103
103
  <Field.Root required>
104
104
  <Field.Label>Invite emails</Field.Label>
105
+ <Field.Description>Press Enter or comma to add each email</Field.Description>
105
106
  <TagGroup.Root
106
107
  name="emails"
107
108
  value={emails}
108
109
  onValueChange={setEmails}
109
110
  onValidate={(v) => (v.includes('@') ? true : 'Invalid email')}
110
111
  >
112
+ <TagGroup.Input placeholder="Add email..." />
111
113
  <TagGroup.List aria-label="Emails">
112
114
  {emails.map((e) => (
113
115
  <Tag key={e} value={e}>
@@ -115,9 +117,7 @@ function EmailExample() {
115
117
  </Tag>
116
118
  ))}
117
119
  </TagGroup.List>
118
- <TagGroup.Input placeholder="Add email..." />
119
120
  </TagGroup.Root>
120
- <Field.Description>Press Enter or comma to add each email</Field.Description>
121
121
  </Field.Root>
122
122
  </Stack>
123
123
  )
@@ -148,6 +148,7 @@ function DisabledExample() {
148
148
  const tags = ['react', 'vue', 'svelte']
149
149
  return (
150
150
  <TagGroup.Root value={tags} disabled>
151
+ <TagGroup.Input placeholder="Disabled..." />
151
152
  <TagGroup.List aria-label="Frameworks">
152
153
  {tags.map((t) => (
153
154
  <Tag key={t} value={t}>
@@ -155,7 +156,6 @@ function DisabledExample() {
155
156
  </Tag>
156
157
  ))}
157
158
  </TagGroup.List>
158
- <TagGroup.Input placeholder="Disabled..." />
159
159
  </TagGroup.Root>
160
160
  )
161
161
  }
@@ -284,7 +284,7 @@ function Input({placeholder}: InputProps) {
284
284
  type="text"
285
285
  value={inputValue}
286
286
  onChange={(e: any) => setInputValue(e.target.value)}
287
- placeholder={!ctx?.values.length ? placeholder : undefined}
287
+ placeholder={placeholder}
288
288
  disabled={ctx?.disabled}
289
289
  style={styles.input}
290
290
  />
@@ -9,85 +9,71 @@ export const styles = css.create({
9
9
  flexDirection: 'column',
10
10
  },
11
11
  container: {
12
+ display: 'flex',
13
+ flexDirection: 'column',
14
+ gap: spacing.sm,
15
+ },
16
+ containerError: {},
17
+ containerDisabled: {
18
+ opacity: 0.5,
19
+ cursor: 'not-allowed',
20
+ },
21
+ // Static display (no Input) — no gap needed
22
+ containerStatic: {
23
+ gap: 0,
24
+ },
25
+ list: {
12
26
  display: 'flex',
13
27
  flexWrap: 'wrap',
14
- alignItems: 'center',
15
28
  gap: spacing.xs,
16
- paddingTop: spacing.xs,
17
- paddingBottom: spacing.xs,
18
- paddingLeft: spacing.sm,
19
- paddingRight: spacing.sm,
29
+ alignItems: 'center',
30
+ },
31
+ input: {
32
+ width: '100%',
20
33
  borderWidth: 1,
21
34
  borderStyle: 'solid',
22
35
  borderColor: {
23
36
  default: colors.border,
24
- ':focus-within': colors.accent,
37
+ ':focus': colors.accent,
25
38
  },
26
39
  borderRadius: radii.sm,
27
40
  backgroundColor: colors.bg,
41
+ fontFamily: typography.fontFamily,
42
+ fontSize: typography.fontSizeSm,
43
+ lineHeight: typography.lineHeight,
44
+ color: colors.text,
45
+ paddingTop: spacing.sm,
46
+ paddingBottom: spacing.sm,
47
+ paddingLeft: spacing.sm,
48
+ paddingRight: spacing.sm,
28
49
  minHeight: 40,
29
50
  transitionProperty: 'border-color',
30
51
  transitionDuration: '150ms',
31
52
  transitionTimingFunction: 'ease',
32
53
  outlineWidth: {
33
54
  default: 0,
34
- ':focus-within': 2,
55
+ ':focus': 2,
35
56
  },
36
57
  outlineStyle: {
37
58
  default: 'none',
38
- ':focus-within': 'solid',
59
+ ':focus': 'solid',
39
60
  },
40
61
  outlineColor: {
41
62
  default: 'transparent',
42
- ':focus-within': colors.accent,
63
+ ':focus': colors.accent,
43
64
  },
44
65
  outlineOffset: {
45
66
  default: 0,
46
- ':focus-within': 1,
67
+ ':focus': 1,
47
68
  },
69
+ boxSizing: 'border-box',
48
70
  },
49
- containerError: {
71
+ inputError: {
50
72
  borderColor: {
51
73
  default: colors.error,
52
- ':focus-within': colors.error,
74
+ ':focus': colors.error,
53
75
  },
54
76
  },
55
- containerDisabled: {
56
- opacity: 0.5,
57
- cursor: 'not-allowed',
58
- },
59
- // Static display (no Input) — no input-field border
60
- containerStatic: {
61
- borderWidth: 0,
62
- paddingTop: 0,
63
- paddingBottom: 0,
64
- paddingLeft: 0,
65
- paddingRight: 0,
66
- minHeight: 0,
67
- backgroundColor: 'transparent',
68
- outlineWidth: 0,
69
- },
70
- list: {
71
- display: 'flex',
72
- flexWrap: 'wrap',
73
- gap: spacing.xs,
74
- alignItems: 'center',
75
- },
76
- input: {
77
- flex: 1,
78
- minWidth: 120,
79
- borderWidth: 0,
80
- backgroundColor: 'transparent',
81
- fontFamily: typography.fontFamily,
82
- fontSize: typography.fontSizeSm,
83
- lineHeight: typography.lineHeight,
84
- color: colors.text,
85
- outlineWidth: 0,
86
- paddingTop: spacing.xs,
87
- paddingBottom: spacing.xs,
88
- paddingLeft: spacing.xs,
89
- paddingRight: spacing.xs,
90
- },
91
77
  liveRegion: {
92
78
  position: 'absolute',
93
79
  width: 1,
@@ -15,6 +15,7 @@ interface TextareaProps {
15
15
  value?: string
16
16
  defaultValue?: string
17
17
  disabled?: boolean
18
+ autoFocus?: boolean
18
19
  onChange?: (e: React.ChangeEvent<HTMLTextAreaElement>) => void
19
20
  onBlur?: () => void
20
21
  ref?: React.Ref<HTMLTextAreaElement>
@@ -29,6 +30,7 @@ export function Textarea({
29
30
  value,
30
31
  defaultValue,
31
32
  disabled,
33
+ autoFocus,
32
34
  onChange,
33
35
  onBlur,
34
36
  ref,
@@ -44,6 +46,9 @@ export function Textarea({
44
46
  const effectiveDisabled = disabled ?? ctx?.disabled
45
47
  const effectiveVariant = ctx?.invalid ? 'error' : variant
46
48
 
49
+ const extraProps: Record<string, unknown> = {}
50
+ if (autoFocus) extraProps.autoFocus = true
51
+
47
52
  return (
48
53
  <html.textarea
49
54
  id={ctx?.controlId}
@@ -62,6 +67,7 @@ export function Textarea({
62
67
  onBlur={effectiveOnBlur as StrictTextareaProps['onBlur']}
63
68
  ref={effectiveRef as React.Ref<HTMLTextAreaElement>}
64
69
  style={[styles.base, styles[effectiveVariant]]}
70
+ {...extraProps}
65
71
  />
66
72
  )
67
73
  }