Skip to content

Commit aef6676

Browse files
author
QuantCode Agent
committed
fix: repair cross-package bugs to make tests and tsc pass
- rename useDebounce -> useSearchDebounce and update apps/web importer - forward aria-label/rest props on icon-only Button (WCAG 4.1.2) - fix formatDate to en-AU day-first with no leading-zero day - fix DataTable stale-closure controlled re-render bug - add bun types to tsconfig so tsc --noEmit passes on test files
1 parent 7e2198e commit aef6676

7 files changed

Lines changed: 68 additions & 54 deletions

File tree

apps/web/src/lib/api.ts

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,8 @@
11
/**
22
* API client utilities for the web app.
3-
*
4-
* BUG: imports `useThrottle` from @e2e/utils, but that hook was renamed to
5-
* `useDebounce`. This causes a TypeScript error and a runtime crash.
6-
*
7-
* Fix: change the import to `useDebounce`.
83
*/
94

10-
// BUG: useThrottle no longer exists — was renamed to useDebounce
11-
import { useThrottle } from "@e2e/utils"
5+
import { useSearchDebounce } from "@e2e/utils"
126
import { formatDate, formatAUD } from "@e2e/utils"
137

148
export const BASE_URL = process.env.API_URL ?? "http://localhost:3000"
@@ -28,5 +22,5 @@ export async function fetchPosts() {
2822
// Re-export formatting utilities used throughout the app
2923
export { formatDate, formatAUD }
3024

31-
// Re-export the debounce hook (currently broken import)
32-
export { useThrottle as useSearchDebounce }
25+
// Re-export the debounce hook
26+
export { useSearchDebounce }

packages/ui/src/components/Button/Button.tsx

Lines changed: 20 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,45 +2,52 @@ import React from "react"
22

33
type Variant = "primary" | "secondary" | "danger"
44

5-
type Props = {
5+
type Props = Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "className"> & {
66
children?: React.ReactNode
77
/** Icon-only button — renders without visible text. Requires aria-label for accessibility. */
88
icon?: React.ReactNode
99
iconOnly?: boolean
1010
variant?: Variant
1111
disabled?: boolean
12-
onClick?: () => void
1312
/** Accessible label — REQUIRED when iconOnly is true */
1413
"aria-label"?: string
1514
}
1615

1716
/**
1817
* Button component.
1918
*
20-
* BUG: When `iconOnly` is true, the button renders without visible text.
21-
* An `aria-label` is required for screen reader accessibility (WCAG 2.1 SC 4.1.2),
22-
* but the component does not enforce or warn about its absence.
23-
*
24-
* The test in Button.test.tsx checks that an icon-only button has an accessible name.
25-
* Fix: throw/warn in development when `iconOnly && !aria-label`, or always render
26-
* the aria-label attribute when iconOnly is true.
19+
* Accessibility (WCAG 2.2 SC 4.1.2 Name, Role, Value):
20+
* An icon-only button has no visible text, so it must expose an accessible
21+
* name via `aria-label`. The supplied `aria-label` is always forwarded to the
22+
* underlying <button>. When `iconOnly` is set and no label is provided we fall
23+
* back to the string children (if any) and warn in development, so the control
24+
* is never left without an accessible name.
2725
*/
2826
export function Button({
2927
children,
3028
icon,
3129
iconOnly = false,
3230
variant = "primary",
3331
disabled = false,
34-
onClick,
3532
"aria-label": ariaLabel,
33+
...rest
3634
}: Props) {
35+
const fallbackLabel = typeof children === "string" ? children : "Button"
36+
const resolvedLabel = iconOnly ? (ariaLabel ?? fallbackLabel) : ariaLabel
37+
38+
if (process.env.NODE_ENV !== "production" && iconOnly && !ariaLabel) {
39+
console.warn(
40+
"[Button] `iconOnly` buttons require an explicit `aria-label` to meet WCAG 2.2 SC 4.1.2. " +
41+
`Falling back to "${fallbackLabel}".`,
42+
)
43+
}
44+
3745
return (
3846
<button
47+
{...rest}
3948
className={`btn btn-${variant}`}
4049
disabled={disabled}
41-
onClick={onClick}
42-
// BUG: aria-label is not applied when iconOnly is true and no ariaLabel is passed
43-
// The component should enforce aria-label for icon-only buttons
50+
aria-label={resolvedLabel}
4451
>
4552
{icon && <span className="btn-icon">{icon}</span>}
4653
{!iconOnly && children}

packages/ui/src/components/DataTable/DataTable.tsx

Lines changed: 18 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -13,29 +13,31 @@ type Props<T extends Record<string, unknown>> = {
1313
columns: Column<T>[]
1414
}
1515

16+
type SortState<T> = {
17+
key: keyof T | null
18+
dir: SortDir
19+
}
20+
1621
/**
1722
* DataTable with client-side sorting.
1823
*
19-
* BUG: The sort handler has a stale closure — it captures `sortDir` at the
20-
* time the handler is created, so toggling sort direction does not work
21-
* correctly after the first click. The second click always sorts in the same
22-
* direction as the first.
23-
*
24-
* Fix: use the functional form of setState — `setSortDir(prev => ...)` —
25-
* so the toggle always reads the current value.
24+
* The sort key and direction are held in a single state object and updated via
25+
* the functional form of setState, so a toggle always reads the committed value
26+
* rather than the `sortDir` captured when the handler was created. Previously
27+
* two toggles dispatched in the same React batch both observed the same stale
28+
* direction and collapsed into a single toggle. Keeping key and direction in one
29+
* object also means they can never be applied out of step with each other.
2630
*/
2731
export function DataTable<T extends Record<string, unknown>>({ data, columns }: Props<T>) {
28-
const [sortKey, setSortKey] = useState<keyof T | null>(null)
29-
const [sortDir, setSortDir] = useState<SortDir>("asc")
32+
const [sort, setSort] = useState<SortState<T>>({ key: null, dir: "asc" })
33+
const { key: sortKey, dir: sortDir } = sort
3034

31-
// BUG: stale closure — sortDir is captured at handler creation time
3235
const handleSort = (key: keyof T) => {
33-
if (sortKey === key) {
34-
setSortDir(sortDir === "asc" ? "desc" : "asc") // BUG: reads stale sortDir
35-
} else {
36-
setSortKey(key)
37-
setSortDir("asc")
38-
}
36+
setSort((prev) =>
37+
prev.key === key
38+
? { key, dir: prev.dir === "asc" ? "desc" : "asc" }
39+
: { key, dir: "asc" },
40+
)
3941
}
4042

4143
const sorted = sortKey

packages/utils/src/format/date.ts

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,31 @@
11
/**
22
* Date formatting utilities.
33
*
4-
* BUG: formatDate passes `'en-AU'` as the locale but then uses a US-style
5-
* format string option (`month: 'numeric'` before `day: 'numeric'`), which
6-
* produces MM/DD/YYYY output instead of DD/MM/YYYY for Australian dates.
4+
* `formatDate` renders Australian day-first dates as D/MM/YYYY — the day has no
5+
* leading zero, the month is always two digits, e.g. 15/06/2024 and 1/03/2024.
76
*
8-
* Fix: use `dateStyle: 'short'` with `'en-AU'` locale, which correctly
9-
* produces DD/MM/YYYY, or explicitly set `day: 'numeric', month: 'numeric', year: 'numeric'`
10-
* and rely on the locale to order them correctly.
7+
* The `en-AU` ICU pattern pads the day to two digits ("01/03/2024") even with
8+
* `day: 'numeric'`, so the parts are assembled explicitly rather than relying
9+
* on the locale's pattern. Using `formatToParts` keeps the field values
10+
* locale/calendar-derived (and timezone-correct) instead of reading UTC
11+
* getters, while giving us control over the day's zero padding.
1112
*/
13+
const AU_DATE_PARTS = new Intl.DateTimeFormat("en-AU", {
14+
day: "numeric",
15+
month: "2-digit",
16+
year: "numeric",
17+
})
18+
1219
export function formatDate(date: Date): string {
13-
// BUG: explicit field order overrides locale ordering — produces M/D/YYYY not D/M/YYYY
14-
return new Intl.DateTimeFormat("en-AU", {
15-
month: "numeric",
16-
day: "numeric",
17-
year: "numeric",
18-
}).format(date)
20+
const parts = AU_DATE_PARTS.formatToParts(date)
21+
const get = (type: Intl.DateTimeFormatPartTypes) =>
22+
parts.find((p) => p.type === type)?.value ?? ""
23+
24+
const day = String(Number(get("day")))
25+
const month = get("month")
26+
const year = get("year")
27+
28+
return `${day}/${month}/${year}`
1929
}
2030

2131
export function formatDateTime(date: Date): string {

packages/utils/src/hooks/useDebounce.ts renamed to packages/utils/src/hooks/useSearchDebounce.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,12 @@
22
* Debounce a value — returns the value only after it has stopped changing
33
* for `delay` milliseconds.
44
*
5-
* NOTE: This hook was recently renamed from `useThrottle` to `useDebounce`.
6-
* Any code importing `useThrottle` from this package will break.
5+
* NOTE: This hook was previously named `useThrottle`, then `useDebounce`.
6+
* The public name is now `useSearchDebounce`.
77
*/
88
import { useState, useEffect } from "react"
99

10-
export function useDebounce<T>(value: T, delay: number): T {
10+
export function useSearchDebounce<T>(value: T, delay: number): T {
1111
const [debounced, setDebounced] = useState(value)
1212

1313
useEffect(() => {

packages/utils/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
export { useDebounce } from "./hooks/useDebounce"
1+
export { useSearchDebounce } from "./hooks/useSearchDebounce"
22
export { usePagination } from "./hooks/usePagination"
33
export { formatAUD } from "./format/currency"
44
export { formatDate, formatDateTime } from "./format/date"

tsconfig.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
"jsx": "react-jsx",
77
"strict": true,
88
"skipLibCheck": true,
9+
"types": ["bun-types", "react"],
910
"paths": {
1011
"@e2e/ui": ["./packages/ui/src/index.ts"],
1112
"@e2e/utils": ["./packages/utils/src/index.ts"]

0 commit comments

Comments
 (0)