@djangocfg/ui-core 2.1.545 → 2.1.547

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@djangocfg/ui-core",
3
- "version": "2.1.545",
3
+ "version": "2.1.547",
4
4
  "description": "Pure React UI component library without Next.js dependencies - for Electron, Vite, CRA apps",
5
5
  "keywords": [
6
6
  "ui-components",
@@ -130,7 +130,7 @@
130
130
  "check:contrast": "node scripts/check-preset-contrast.mjs"
131
131
  },
132
132
  "peerDependencies": {
133
- "@djangocfg/i18n": "^2.1.545",
133
+ "@djangocfg/i18n": "^2.1.547",
134
134
  "consola": "^3.4.2",
135
135
  "lucide-react": "^0.545.0",
136
136
  "moment": "^2.30.1",
@@ -206,9 +206,9 @@
206
206
  "vaul": "1.1.2"
207
207
  },
208
208
  "devDependencies": {
209
- "@djangocfg/eslint-config": "^2.1.545",
210
- "@djangocfg/i18n": "^2.1.545",
211
- "@djangocfg/typescript-config": "^2.1.545",
209
+ "@djangocfg/eslint-config": "^2.1.547",
210
+ "@djangocfg/i18n": "^2.1.547",
211
+ "@djangocfg/typescript-config": "^2.1.547",
212
212
  "@storybook/react-vite": "^10.5.0",
213
213
  "@types/node": "^24.13.3",
214
214
  "@types/react": "19.2.15",
@@ -1,9 +1,9 @@
1
1
  "use client"
2
2
 
3
- import { Check } from "lucide-react"
4
3
  import * as React from "react"
5
4
 
6
5
  import { cn } from "../../../lib/utils"
6
+ import { Checkbox } from "../checkbox"
7
7
 
8
8
  // ─────────────────────────────────────────────────────────────────────────────
9
9
  // Types
@@ -11,9 +11,13 @@ import { cn } from "../../../lib/utils"
11
11
 
12
12
  interface CheckboxGroupContextValue {
13
13
  value: string[]
14
- onValueChange: (value: string[]) => void
14
+ toggle: (value: string) => void
15
15
  disabled: boolean
16
16
  name?: string
17
+ /** Ties `CheckboxGroupLabel` to the group's `aria-labelledby`. */
18
+ labelId: string
19
+ /** Ties every item's `aria-describedby` to the group's description. */
20
+ descriptionId: string
17
21
  }
18
22
 
19
23
  const CheckboxGroupContext = React.createContext<CheckboxGroupContextValue | null>(null)
@@ -45,6 +49,7 @@ function CheckboxGroup({
45
49
  onValueChange,
46
50
  disabled = false,
47
51
  name,
52
+ "aria-labelledby": ariaLabelledBy,
48
53
  ...props
49
54
  }: CheckboxGroupProps) {
50
55
  const isControlled = valueProp !== undefined
@@ -52,32 +57,53 @@ function CheckboxGroup({
52
57
 
53
58
  const value = isControlled ? valueProp : uncontrolledValue
54
59
 
55
- const handleValueChange = React.useCallback(
56
- (nextValue: string[]) => {
57
- if (!isControlled) {
58
- setUncontrolledValue(nextValue)
60
+ // Ids for the label and description, so the group announces as one thing
61
+ // rather than as a run of unrelated checkboxes.
62
+ const id = React.useId()
63
+ const labelId = `${id}-label`
64
+ const descriptionId = `${id}-description`
65
+
66
+ // The membership rule lives here, not on the item: an item that computed the
67
+ // next array from a captured `value` toggled against a stale snapshot when
68
+ // two arrived in one tick.
69
+ const toggle = React.useCallback(
70
+ (member: string) => {
71
+ const from = isControlled ? valueProp : undefined
72
+ const next = (current: string[]) =>
73
+ current.includes(member)
74
+ ? current.filter((entry) => entry !== member)
75
+ : [...current, member]
76
+
77
+ if (isControlled) {
78
+ onValueChange?.(next(from ?? []))
79
+ return
59
80
  }
60
- onValueChange?.(nextValue)
81
+ setUncontrolledValue((current) => {
82
+ const value = next(current)
83
+ onValueChange?.(value)
84
+ return value
85
+ })
61
86
  },
62
- [isControlled, onValueChange],
87
+ [isControlled, valueProp, onValueChange],
63
88
  )
64
89
 
65
90
  const contextValue = React.useMemo<CheckboxGroupContextValue>(
66
- () => ({
67
- value,
68
- onValueChange: handleValueChange,
69
- disabled,
70
- name,
71
- }),
72
- [value, handleValueChange, disabled, name],
91
+ () => ({ value, toggle, disabled, name, labelId, descriptionId }),
92
+ [value, toggle, disabled, name, labelId, descriptionId],
73
93
  )
74
94
 
75
95
  return (
76
96
  <CheckboxGroupContext.Provider value={contextValue}>
97
+ {/* `role="group"`: without it a screen reader reads the items as
98
+ unrelated checkboxes and never says what they have in common.
99
+ `CheckboxGroupLabel` names it; a caller passing its own
100
+ `aria-labelledby` wins. */}
77
101
  <div
102
+ role="group"
103
+ aria-labelledby={ariaLabelledBy ?? labelId}
78
104
  data-slot="checkbox-group"
79
105
  data-disabled={disabled ? "" : undefined}
80
- className={cn("peer flex flex-col gap-3.5", className)}
106
+ className={cn("peer flex flex-col gap-2", className)}
81
107
  {...props}
82
108
  />
83
109
  </CheckboxGroupContext.Provider>
@@ -88,9 +114,11 @@ function CheckboxGroup({
88
114
  // CheckboxGroupLabel
89
115
  // ─────────────────────────────────────────────────────────────────────────────
90
116
 
91
- function CheckboxGroupLabel({ className, ...props }: React.ComponentProps<"div">) {
117
+ function CheckboxGroupLabel({ className, id, ...props }: React.ComponentProps<"div">) {
118
+ const context = useCheckboxGroupContext("CheckboxGroupLabel")
92
119
  return (
93
120
  <div
121
+ id={id ?? context.labelId}
94
122
  data-slot="checkbox-group-label"
95
123
  className={cn(
96
124
  "text-foreground/70 text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
@@ -115,12 +143,16 @@ function CheckboxGroupList({
115
143
  ...props
116
144
  }: CheckboxGroupListProps) {
117
145
  return (
146
+ // Optional: the root already stacks its children, so this exists for the
147
+ // horizontal case and for separating the items from a label and a
148
+ // description that share the root. Same `gap-2` either way — one spacing
149
+ // for one component.
118
150
  <div
119
151
  data-slot="checkbox-group-list"
120
152
  data-orientation={orientation}
121
153
  className={cn(
122
- "flex gap-3",
123
- orientation === "horizontal" ? "flex-row" : "flex-col",
154
+ "flex gap-2",
155
+ orientation === "horizontal" ? "flex-row flex-wrap items-center" : "flex-col",
124
156
  className,
125
157
  )}
126
158
  {...props}
@@ -132,67 +164,58 @@ function CheckboxGroupList({
132
164
  // CheckboxGroupItem
133
165
  // ─────────────────────────────────────────────────────────────────────────────
134
166
 
135
- interface CheckboxGroupItemProps extends Omit<React.ComponentProps<"button">, "value"> {
167
+ interface CheckboxGroupItemProps
168
+ extends Omit<React.ComponentProps<typeof Checkbox>, "value" | "checked" | "onCheckedChange"> {
136
169
  value: string
137
170
  disabled?: boolean
171
+ /** Classes for the ROW. The box itself takes `checkboxClassName`. */
172
+ className?: string
173
+ checkboxClassName?: string
138
174
  }
139
175
 
140
176
  function CheckboxGroupItem({
141
177
  className,
178
+ checkboxClassName,
142
179
  children,
143
180
  value,
144
181
  disabled: disabledProp,
145
- onClick,
146
182
  ...props
147
183
  }: CheckboxGroupItemProps) {
148
184
  const context = useCheckboxGroupContext("CheckboxGroupItem")
149
185
  const isDisabled = disabledProp || context.disabled
150
186
  const isChecked = context.value.includes(value)
151
187
 
152
- const handleClick = React.useCallback(
153
- (event: React.MouseEvent<HTMLButtonElement>) => {
154
- onClick?.(event)
155
- if (isDisabled) return
156
-
157
- const nextValue = isChecked
158
- ? context.value.filter((v) => v !== value)
159
- : [...context.value, value]
160
-
161
- context.onValueChange(nextValue)
162
- },
163
- [onClick, isDisabled, isChecked, context, value],
164
- )
165
-
166
188
  return (
189
+ // The whole row is the target, not just the box: a 16px hit area for a
190
+ // choice the row already names is the commonest reason a list like this
191
+ // feels unresponsive. `w-full`, so the row spans its list.
167
192
  <label
193
+ data-slot="checkbox-group-row"
194
+ data-state={isChecked ? "checked" : "unchecked"}
195
+ data-disabled={isDisabled ? "" : undefined}
168
196
  className={cn(
169
- "flex w-fit select-none items-center gap-2 text-sm leading-none",
170
- "has-data-disabled:cursor-not-allowed has-data-disabled:opacity-50",
197
+ "flex w-full select-none items-center gap-2 text-sm leading-none",
198
+ isDisabled ? "cursor-not-allowed opacity-50" : "cursor-pointer",
199
+ className,
171
200
  )}
172
201
  >
173
- <button
174
- type="button"
175
- role="checkbox"
176
- aria-checked={isChecked}
202
+ {/* The shared `Checkbox`, not a second implementation of one. The
203
+ hand-rolled button here carried its own border, size and tick, so a
204
+ form holding both drew two different checkboxes. */}
205
+ <Checkbox
177
206
  data-slot="checkbox-group-item"
178
- data-state={isChecked ? "checked" : "unchecked"}
179
- data-disabled={isDisabled ? "" : undefined}
180
- disabled={isDisabled}
181
- onClick={handleClick}
182
- className={cn(
183
- "size-4 shrink-0 rounded-sm border border-primary shadow-sm",
184
- "focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring",
185
- "data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
186
- className,
187
- )}
207
+ aria-describedby={context.descriptionId}
188
208
  {...props}
189
- >
190
- {isChecked && (
191
- <span className="flex items-center justify-center text-current">
192
- <Check className="size-3.5" />
193
- </span>
194
- )}
195
- </button>
209
+ checked={isChecked}
210
+ onCheckedChange={() => context.toggle(value)}
211
+ disabled={isDisabled}
212
+ className={checkboxClassName}
213
+ />
214
+ {/* Submitted with the form. `name` was accepted and then dropped, so a
215
+ native submit sent nothing for a group the caller had named. */}
216
+ {context.name && isChecked ? (
217
+ <input type="hidden" name={context.name} value={value} />
218
+ ) : null}
196
219
  {children}
197
220
  </label>
198
221
  )
@@ -202,9 +225,11 @@ function CheckboxGroupItem({
202
225
  // CheckboxGroupDescription
203
226
  // ─────────────────────────────────────────────────────────────────────────────
204
227
 
205
- function CheckboxGroupDescription({ className, ...props }: React.ComponentProps<"div">) {
228
+ function CheckboxGroupDescription({ className, id, ...props }: React.ComponentProps<"div">) {
229
+ const context = useCheckboxGroupContext("CheckboxGroupDescription")
206
230
  return (
207
231
  <div
232
+ id={id ?? context.descriptionId}
208
233
  data-slot="checkbox-group-description"
209
234
  className={cn(
210
235
  "text-[0.8rem] text-muted-foreground leading-none",