diff --git a/.changeset/config.json b/.changeset/config.json index fcfcf83e0..a1571f742 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -2,7 +2,7 @@ "$schema": "https://unpkg.com/@changesets/config@3.0.0/schema.json", "changelog": "@changesets/cli/changelog", "commit": false, - "fixed": [], + "fixed": [["@javaabu/onu", "@javaabu/onu-mcp"]], "linked": [], "access": "public", "baseBranch": "main", diff --git a/.changeset/drop-unused-peers.md b/.changeset/drop-unused-peers.md new file mode 100644 index 000000000..dd1573a71 --- /dev/null +++ b/.changeset/drop-unused-peers.md @@ -0,0 +1,5 @@ +--- +"@javaabu/onu": patch +--- + +Removed four optional peers nothing imports: `@tiptap/extension-link`, `@tiptap/extension-drag-handle-react`, `@tiptap/extension-code-block-lowlight` and `lowlight`. diff --git a/.changeset/editor-dialog-thaana.md b/.changeset/editor-dialog-thaana.md new file mode 100644 index 000000000..ad73e8ced --- /dev/null +++ b/.changeset/editor-dialog-thaana.md @@ -0,0 +1,5 @@ +--- +"@javaabu/onu": patch +--- + +The editors' image alt text field now follows the editor's `thaana` prop. diff --git a/.changeset/hard-coded-english.md b/.changeset/hard-coded-english.md new file mode 100644 index 000000000..480b2e7e9 --- /dev/null +++ b/.changeset/hard-coded-english.md @@ -0,0 +1,5 @@ +--- +"@javaabu/onu": patch +--- + +`DialogFooter`'s close button, the `useConfirm` `window.confirm` fallback and a `ValidationError` without a message no longer show hard-coded English. diff --git a/.changeset/labels-with-placeholders.md b/.changeset/labels-with-placeholders.md new file mode 100644 index 000000000..0336060d2 --- /dev/null +++ b/.changeset/labels-with-placeholders.md @@ -0,0 +1,5 @@ +--- +"@javaabu/onu": patch +--- + +The move, switch account and inline edit labels are now whole translated sentences with a placeholder, instead of two strings joined together. diff --git a/.changeset/logical-sides.md b/.changeset/logical-sides.md new file mode 100644 index 000000000..99e2cb192 --- /dev/null +++ b/.changeset/logical-sides.md @@ -0,0 +1,5 @@ +--- +"@javaabu/onu": minor +--- + +`Tip`, `PopoverPanel` and `ActionMenu` take `side="top" | "bottom" | "start" | "end"`; `left` and `right` are gone. diff --git a/.changeset/overlay-token.md b/.changeset/overlay-token.md new file mode 100644 index 000000000..1b5632ffd --- /dev/null +++ b/.changeset/overlay-token.md @@ -0,0 +1,5 @@ +--- +"@javaabu/onu": patch +--- + +New `--onu-overlay` token for the dim behind dialogs, sheets and drawers (`bg-overlay`). The app shell classes use the shell token utilities instead of hex fallbacks. diff --git a/.changeset/peer-form-and-query.md b/.changeset/peer-form-and-query.md new file mode 100644 index 000000000..e44f976d4 --- /dev/null +++ b/.changeset/peer-form-and-query.md @@ -0,0 +1,5 @@ +--- +"@javaabu/onu": minor +--- + +`react-hook-form` and `@tanstack/react-query` are now peer dependencies. `getOnuQueryClient` is gone: the fallback client belongs to each `OnuProvider`, and `invalidateApiOptions` takes the client first. diff --git a/.changeset/react-19-only.md b/.changeset/react-19-only.md new file mode 100644 index 000000000..26b0686b2 --- /dev/null +++ b/.changeset/react-19-only.md @@ -0,0 +1,5 @@ +--- +"@javaabu/onu": minor +--- + +Onu now requires React 19; React 18 is no longer in the peer range. diff --git a/.changeset/remove-base-ui-combobox.md b/.changeset/remove-base-ui-combobox.md new file mode 100644 index 000000000..d3b40b2b4 --- /dev/null +++ b/.changeset/remove-base-ui-combobox.md @@ -0,0 +1,5 @@ +--- +"@javaabu/onu": minor +--- + +Removed the unused `Combobox` primitive from `@javaabu/onu/primitives`, and with it the `@base-ui/react` dependency. diff --git a/.changeset/segmented-control-name.md b/.changeset/segmented-control-name.md new file mode 100644 index 000000000..a532f8f07 --- /dev/null +++ b/.changeset/segmented-control-name.md @@ -0,0 +1,5 @@ +--- +"@javaabu/onu": patch +--- + +`SegmentedControl`'s `name` prop is now part of its type. diff --git a/.changeset/stable-first-render.md b/.changeset/stable-first-render.md new file mode 100644 index 000000000..8e67d99dd --- /dev/null +++ b/.changeset/stable-first-render.md @@ -0,0 +1,5 @@ +--- +"@javaabu/onu": patch +--- + +`Countdown`, `RelativeDate`, `Timeline`, `PublishBox` and `SidebarMenuSkeleton` render the same markup on the server and the client's first render. `SidebarMenuSkeleton` takes an `index` to vary its width. diff --git a/.changeset/stable-labels.md b/.changeset/stable-labels.md new file mode 100644 index 000000000..049985d91 --- /dev/null +++ b/.changeset/stable-labels.md @@ -0,0 +1,5 @@ +--- +"@javaabu/onu": patch +--- + +The vertical `Carousel` arrows point the right way in RTL, and `KanbanBoard`'s column count is real screen reader text instead of an `aria-label` on a `span`. diff --git a/.changeset/thaana-fonts-always.md b/.changeset/thaana-fonts-always.md new file mode 100644 index 000000000..134cc89d2 --- /dev/null +++ b/.changeset/thaana-fonts-always.md @@ -0,0 +1,5 @@ +--- +"@javaabu/onu": patch +--- + +Faruma and MV Faseyha now load from `tailwind.css` and `styles.css`, so Thaana text works without the optional font pack. diff --git a/.changeset/toast-close-size.md b/.changeset/toast-close-size.md new file mode 100644 index 000000000..bc24348b3 --- /dev/null +++ b/.changeset/toast-close-size.md @@ -0,0 +1,5 @@ +--- +"@javaabu/onu": patch +--- + +The toast close button is now 24 px, and 44 px on touch screens. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 202f2020e..5041de7ec 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -41,6 +41,7 @@ jobs: exit 1 fi done + node scripts/sync-plugin-version.mjs --check - name: Check the npm token run: | if [ -z "$NPM_TOKEN" ]; then diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f6c04d146..605850660 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -85,10 +85,10 @@ Every change that affects a published package adds a changeset (`pnpm changeset` To release: -1. On `dev`, run `pnpm changeset version`. It bumps every package with pending changesets, writes their `CHANGELOG.md` and deletes the changesets. All packages share one version, so check that `packages/onu` and `packages/onu-mcp` match. +1. On `dev`, run `pnpm changeset version && node scripts/sync-plugin-version.mjs`. `@javaabu/onu` and `@javaabu/onu-mcp` are a fixed group in `.changeset/config.json`, so they always move to the same version; the script copies that version into `plugins/onu/.claude-plugin/plugin.json`, which changesets can't see. `changeset version` also writes their `CHANGELOG.md` and deletes the changesets. 2. Commit (`Release v0.1.0`) and merge `dev` into `main`. Wait for CI to pass on `main`. 3. Tag that commit on `main` and push the tag: `git tag v0.1.0 && git push origin v0.1.0`. -The **Release** workflow runs only for `v*` tags. It checks that the tagged commit is on `main` and that every package's version matches the tag, then builds and publishes to npm (versions already on npm are skipped). Tags with a pre-release suffix (`v1.0.0-beta.1`) publish under the `next` dist-tag. +The **Release** workflow runs only for `v*` tags. It checks that the tagged commit is on `main` and that every package's version and the plugin's match the tag, then builds and publishes to npm (versions already on npm are skipped). Tags with a pre-release suffix (`v1.0.0-beta.1`) publish under the `next` dist-tag. Publishing needs an `NPM_TOKEN` repository secret: an npm granular access token with read and write access to the `@javaabu` scope and "bypass 2FA" allowed, from an account that can publish to `@javaabu`. Write tokens expire after about 90 days. Provenance is signed only once the repository is public. After the first release, you can switch to npm trusted publishing (each package's settings on npmjs.com, pointing at `release.yml`) and remove the token. diff --git a/DEPENDENCIES.md b/DEPENDENCIES.md index 5a2362888..098c3c852 100644 --- a/DEPENDENCIES.md +++ b/DEPENDENCIES.md @@ -9,7 +9,6 @@ Selection order: shadcn/ui component → reputable library (shadcn ecosystem fir | Package | Purpose | Licence | Notes | | -------------------------------------------------- | -------------------------------------------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------ | | `radix-ui` | Accessible primitives behind the shadcn components | MIT | shadcn default base | -| `@base-ui/react` | Combobox primitive (shadcn's combobox) | MIT | From the Radix / MUI team | | `class-variance-authority` | Variants (`buttonVariants`, …) | Apache-2.0 | Stable and feature-complete; last release 2024 (exception to the 12-month rule, used by shadcn) | | `clsx`, `tailwind-merge` | `cn()` class merging so consumer classes win | MIT | | | `tw-animate-css` | Enter/exit animations | MIT | shadcn default | @@ -19,25 +18,27 @@ Selection order: shadcn/ui component → reputable library (shadcn ecosystem fir | `vaul` | Drawer / bottom sheet | MIT | shadcn `drawer`. **Watch-list**: reduced maintenance (R4.3); kept behind Onu's API | | `input-otp` | OTP input | MIT | shadcn `input-otp` | | `react-day-picker`, `date-fns` | Calendar and date maths | MIT | shadcn `calendar`; Dhivehi locale maintained in Onu | -| `@internationalized/date` | Hijri (Umm al-Qura) calendar maths, date/time fields | Apache-2.0 | Adobe | -| `react-aria-components` | Tree, colour picker, date/time segment fields | Apache-2.0 | Adobe; fills Radix gaps (§4 step 3) | +| `@internationalized/date` | Hijri (Umm al-Qura) calendar maths, date/time segment fields | Apache-2.0 | Adobe | +| `react-aria-components` | Tree, colour picker | Apache-2.0 | Adobe; fills Radix gaps (§4 step 3) | | `embla-carousel-react` | Carousel | MIT | shadcn `carousel` | -| `react-hook-form` | Form state | MIT | | | `@tanstack/react-table`, `@tanstack/react-virtual` | Tables, data grid, virtualised lists | MIT | | -| `@tanstack/react-query` | API select caching and de-duplication | MIT | R16.5 | | `@dnd-kit/react`, `@dnd-kit/helpers` | Drag and drop (kanban, sortable lists, row reordering) with touch sensors | MIT | The maintained successor of `@dnd-kit/core` | | `@dnd-kit/dom` | dnd-kit's `Accessibility` plugin, configured with translated announcements | MIT | Already installed by `@dnd-kit/react`; imported directly so drag announcements follow the locale | | `react-phone-number-input` | Phone input (libphonenumber) | MIT | Default region MV | | `@sindresorhus/slugify` | Slug generator | MIT | | | `uqr` | QR code encoding for `QrCode` (media entry); Onu renders the SVG | MIT | unjs; zero-dep, typed. Picked over `qrcode` (Node deps), `qrcode-generator` (stale) | +## Peers (core) + +`react-hook-form` (form state, MIT) and `@tanstack/react-query` (API select caching and de-duplication, R16.5, MIT) are required peers, alongside `react` and `react-dom`. Onu reads their React context, so the app and Onu must resolve the same copy. They stay required because the core entry imports them statically. + ## Optional peers (entry points) | Entry | Packages | Licence | | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------- | | `@javaabu/onu/charts` | `recharts` | MIT | | `@javaabu/onu/maps` | `@vis.gl/react-google-maps`, `terra-draw`, `terra-draw-google-maps-adapter`, `@googlemaps/markerclusterer`, `@deck.gl/core`, `@deck.gl/layers`, `@deck.gl/aggregation-layers`, `@deck.gl/google-maps`, `@turf/turf` | MIT / Apache-2.0 | -| `@javaabu/onu/editor` | `@tiptap/core`, `@tiptap/react`, `@tiptap/pm`, `@tiptap/starter-kit`, `@tiptap/static-renderer`, `@tiptap/suggestion` (slash menu), `@tiptap/extension-*` (MIT packages only), `lowlight`, `shiki` | MIT | +| `@javaabu/onu/editor` | `@tiptap/core`, `@tiptap/react`, `@tiptap/pm`, `@tiptap/starter-kit`, `@tiptap/static-renderer`, `@tiptap/suggestion` (slash menu), `@tiptap/extension-*` (MIT packages only), `shiki` | MIT | | `@javaabu/onu/calendar` | `@fullcalendar/core`, `@fullcalendar/react`, `@fullcalendar/daygrid`, `@fullcalendar/timegrid`, `@fullcalendar/list`, `@fullcalendar/interaction` (standard plugins only, v6) | MIT | | `@javaabu/onu/media` | `react-easy-crop`, `signature_pad`, `barcode-detector`, `yet-another-react-lightbox`, `media-chrome` | MIT | | `@javaabu/onu/export` | `write-excel-file` (Excel `.xlsx` only; CSV and PDF need nothing). Loaded with `import()` on the first Excel export. Chosen over the npm `xlsx` (SheetJS) package, whose npm release is outdated; supports right-to-left sheets and keeps Thaana text intact | MIT | diff --git a/README.md b/README.md index 10a9fbe8a..fae963795 100644 --- a/README.md +++ b/README.md @@ -24,8 +24,8 @@ See [`REQUIREMENTS.md`](REQUIREMENTS.md) for the full specification and [`docs/` ## Install ```bash -npm install @javaabu/onu -# optional font pack (Inter, JetBrains Mono, Noto Sans Thaana; Faruma and MV Faseyha are bundled) +npm install @javaabu/onu react-hook-form @tanstack/react-query +# optional font pack (Inter, JetBrains Mono, Noto Sans Thaana; Faruma and MV Faseyha come with tailwind.css and styles.css) npm install @fontsource-variable/inter @fontsource-variable/jetbrains-mono @fontsource-variable/noto-sans-thaana ``` diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md index dee9bb8db..d8d75c590 100644 --- a/REQUIREMENTS.md +++ b/REQUIREMENTS.md @@ -86,7 +86,7 @@ This document captures the requirements, constraints and full build list for Onu - `@javaabu/onu/styles.css` — theme tokens + base styles - `@javaabu/onu/tailwind.css` — Tailwind `@theme` / `@source` entry for consumers - **R2.6** Heavy or licensed third-party dependencies (map SDKs, chart libs, editors, scanners, FontAwesome, fonts) are **optional peer dependencies**, required only when the matching subpath is imported. -- **R2.7** Peer dependencies: `react` and `react-dom` `^18.3 || ^19`, `tailwindcss` `^4`. +- **R2.7** Peer dependencies: `react` and `react-dom` `^19`, `tailwindcss` `^4`. - **R2.8** Ship TypeScript declarations; source written in TypeScript `strict` mode. No `any` in public APIs. - **R2.9** Every client component file carries a `"use client"` directive so it works in React Server Component environments (Next.js App Router). - **R2.10** Semantic versioning. Releases managed with [Changesets](https://github.com/changesets/changesets); `CHANGELOG.md` generated from them. @@ -181,7 +181,7 @@ A dependency must meet **all** of these criteria: - **Maintained**: a release or meaningful commit within the last 12 months, a responsive issue tracker, and not archived or deprecated. - **Reputable**: widely adopted, and backed by an organisation or established maintainers. - **Typed**: first-party TypeScript types. -- **Compatible**: React 18 and 19, SSR-safe, tree-shakable. +- **Compatible**: React 19, SSR-safe, tree-shakable. - **Accessible and RTL-capable**, or can be made so by wrapping. - **Reasonable bundle size** for what it provides. diff --git a/docs/choice-inputs/api-select.md b/docs/choice-inputs/api-select.md index 860304b4d..ce8f32e61 100644 --- a/docs/choice-inputs/api-select.md +++ b/docs/choice-inputs/api-select.md @@ -35,7 +35,7 @@ import { ApiSelect } from '@javaabu/onu'; - **Paging**: when the response says there is a next page (`meta.last_page`, `last_page`, `next_page_url`, `links.next`, `next_cursor`), the next page loads as the list scrolls to the end. - **Preloading**: options load when the list opens, or immediately with `preload`. - **States**: loading, empty ("No results for …"), and an error with a **Retry** button. All are announced in a polite live region. -- **Caching**: per endpoint + params + query, for `staleTime` (5 minutes). After creating a record elsewhere, call `invalidateApiOptions(url)` (or `queryClient.invalidateQueries({ queryKey: apiOptionsQueryKey(url) })` with your own client). Quick-create (`onCreate`) invalidates automatically. +- **Caching**: per endpoint + params + query, for `staleTime` (5 minutes). After creating a record elsewhere, call `invalidateApiOptions(client, url)` with the client from `useOnuQueryClient()` (or `queryClient.invalidateQueries({ queryKey: apiOptionsQueryKey(url) })` with your own). Quick-create (`onCreate`) invalidates automatically. - **No requests without a URL or fetcher** you supply; CSRF and auth headers are yours to add (`headers`, `credentials`). ## Props @@ -70,7 +70,7 @@ Values are strings (`id: 12` becomes `"12"`), so they round-trip through forms u ## Data layer -The helpers behind it are exported for Autocomplete and your own components: `fetchApiOptions`, `parseApiResponse`, `parseApiItem`, `mapApiItem`, `detectHasMore`, `buildUrl`, `requestJson`, `useOnuQueryClient`, `getOnuQueryClient`, `invalidateApiOptions`, `apiOptionsQueryKey`. +The helpers behind it are exported for Autocomplete and your own components: `fetchApiOptions`, `parseApiResponse`, `parseApiItem`, `mapApiItem`, `detectHasMore`, `buildUrl`, `requestJson`, `useOnuQueryClient`, `invalidateApiOptions`, `apiOptionsQueryKey`. ## Accessibility diff --git a/docs/guides/dhivehi-and-locales.md b/docs/guides/dhivehi-and-locales.md index b7f38287e..696e5743a 100644 --- a/docs/guides/dhivehi-and-locales.md +++ b/docs/guides/dhivehi-and-locales.md @@ -101,4 +101,4 @@ Charts have their own `useChartFormat` in `@javaabu/onu/charts`, with axis and p # Fonts -Dhivehi text uses Faruma, with MV Faseyha for headings and bold, both bundled with `fonts.css`. See [Fonts](./fonts). +Dhivehi text uses Faruma, with MV Faseyha for headings and bold, both loaded by `tailwind.css`. See [Fonts](./fonts). diff --git a/docs/guides/fonts.md b/docs/guides/fonts.md index d9b07b20e..0e7ee4bb1 100644 --- a/docs/guides/fonts.md +++ b/docs/guides/fonts.md @@ -29,13 +29,11 @@ All of these are self-hosted from npm, so no requests go to a font CDN. # Thaana fonts -Onu ships Faruma and MV Faseyha with `fonts.css`, as `woff2` files subset to Thaana and its punctuation (about 12 kB each). Their `unicode-range` means a browser only downloads them when a page shows Thaana text, and Latin text in a mixed sentence stays in Inter. +`tailwind.css` and `styles.css` load Faruma and MV Faseyha, so they work without `fonts.css` and its packages. They ship as `woff2` files subset to Thaana and its punctuation (about 12 kB each). Their `unicode-range` means a browser only downloads them when a page shows Thaana text, and Latin text in a mixed sentence stays in Inter. - **Faruma** is used for Dhivehi body text (`--onu-font-thaana`). - **MV Faseyha** is used for Dhivehi headings and ``/`` (`--onu-font-thaana-bold`). Both fonts have a single weight and Onu turns off faux bold for Dhivehi, so emphasis uses the second face instead. -- Characters neither font covers fall back to Noto Sans Thaana. - -If you don't import `fonts.css`, the same names still apply to copies installed on the device. +- Characters neither font covers fall back to Noto Sans Thaana when you import `fonts.css`. # Mixed Dhivehi and English diff --git a/docs/guides/inertia-and-nextjs.md b/docs/guides/inertia-and-nextjs.md index e587ff4a4..c92ec006b 100644 --- a/docs/guides/inertia-and-nextjs.md +++ b/docs/guides/inertia-and-nextjs.md @@ -3,7 +3,7 @@ title: Inertia & Next.js sidebar_position: 13 --- -Onu is plain React, so it works in Vite apps, Laravel + Inertia (React) and Next.js. +Onu is plain React (React 19), so it works in Vite apps, Laravel + Inertia (React) and Next.js. # Links diff --git a/docs/guides/theming.md b/docs/guides/theming.md index f924a1cde..e8ef340c4 100644 --- a/docs/guides/theming.md +++ b/docs/guides/theming.md @@ -92,6 +92,7 @@ Every token is a CSS variable you can set. The names follow shadcn/ui, prefixed | Sidebar | `--onu-sidebar`, `--onu-sidebar-foreground`, `--onu-sidebar-primary`, `--onu-sidebar-accent`, `--onu-sidebar-border` | | App shell | `--onu-shell`, `--onu-shell-foreground`, `--onu-shell-border`, `--onu-shell-field`, `--onu-shell-hover`, `--onu-shell-active`, `--onu-shell-weak` (see [App shell tokens](#app-shell-tokens)) | | Layout grey | `--onu-layout` (Ant's `colorBgLayout`: the grey page behind dashboard cards) | +| Overlay | `--onu-overlay` (the dim behind modal dialogs, sheets and drawers) | | Preset colours | `--onu-preset-{blue,cyan,green,red,gold}` with `-border` and `-foreground` (see [Preset colours](#preset-colours)) | | Elevation | `--onu-shadow-popup` (the one shadow: menus, selects, popovers, dialogs) | | Shape | `--onu-radius` (every radius derives from it; `0` gives square corners) | diff --git a/docs/installation-and-setup.md b/docs/installation-and-setup.md index bf6c52a9a..7ab316eea 100644 --- a/docs/installation-and-setup.md +++ b/docs/installation-and-setup.md @@ -5,10 +5,10 @@ sidebar_position: 1.2 # Installation -First install the NPM package +Onu needs React 19. First install the NPM package ```bash -npm install @javaabu/onu --save +npm install @javaabu/onu react-hook-form @tanstack/react-query --save ``` # Styles @@ -25,7 +25,7 @@ npm install @fontsource-variable/inter @fontsource-variable/jetbrains-mono @font @import "@javaabu/onu/fonts.css"; /* optional: needs the font packages above */ ``` -Without the font pack, leave out the last line. Faruma and MV Faseyha (Thaana) are inside `@javaabu/onu`; see [Fonts](./guides/fonts). +Without the font pack, leave out the last line. `tailwind.css` already loads Faruma and MV Faseyha (Thaana); see [Fonts](./guides/fonts). `tailwind.css` adds Onu's design tokens, the dark mode variant, and tells Tailwind to scan Onu's components for classes. diff --git a/docs/overlays/action-menu.md b/docs/overlays/action-menu.md index 2ca163b4d..c55775b9b 100644 --- a/docs/overlays/action-menu.md +++ b/docs/overlays/action-menu.md @@ -65,7 +65,7 @@ Without a `trigger`, a ⋯ icon button labelled "More actions" opens the menu (4 | `className` | `string` | | Classes for the menu | | `classNames` | `{ trigger, item }` | | Classes for the default trigger (or the area) and items | -Other props go to Radix `DropdownMenu.Content` (`side`, `sideOffset`, …), or `ContextMenu.Content` with `openOn="context"`, where the menu opens at the pointer and `align`, `side` and `sideOffset` do not apply. +`side` is `top`, `bottom`, `start` or `end`, which follow the reading direction. Other props go to Radix `DropdownMenu.Content` (`sideOffset`, …), or `ContextMenu.Content` with `openOn="context"`, where the menu opens at the pointer and `align`, `side` and `sideOffset` do not apply. ## Row actions diff --git a/docs/overlays/popover-panel.md b/docs/overlays/popover-panel.md index 007c3c570..5e2023226 100644 --- a/docs/overlays/popover-panel.md +++ b/docs/overlays/popover-panel.md @@ -40,7 +40,8 @@ Below 768px this renders a `BottomSheet` with the same title, content and footer | `footer` | `ReactNode` | | Actions, aligned to the inline end | | `closeButton` | `boolean` | `false` | Show a × button | | `mobile` | `'popover' \| 'sheet'` | `'popover'` | Presentation on phones | -| `side`, `align`, `sideOffset`, … | | | Radix `Popover.Content` positioning props | +| `side` | `"top" \| "bottom" \| "start" \| "end"` | | Side it opens on; `start` and `end` follow the reading direction | +| `align`, `sideOffset`, … | | | Radix `Popover.Content` props | | `open`, `defaultOpen`, `onOpenChange` | | | Open state | | `className` | `string` | | Classes for the panel | | `classNames` | `{ header, title, description, body, footer, close }` | | Classes for each part | @@ -58,4 +59,4 @@ Below 768px this renders a `BottomSheet` with the same title, content and footer ## RTL -`side="left"` / `"right"` are physical (Radix); prefer `top` / `bottom` with `align="start" | "end"`, which follow the direction. +`side="start"` / `"end"` and `align="start"` / `"end"` follow the reading direction. diff --git a/docs/overlays/tip.md b/docs/overlays/tip.md index ea1198702..504c0ed92 100644 --- a/docs/overlays/tip.md +++ b/docs/overlays/tip.md @@ -42,7 +42,8 @@ Tapping elsewhere or pressing Escape closes it. | `delayDuration` | `number` | `300` | Hover delay in ms | | `disabled` | `boolean` | `false` | Render only the trigger | | `open`, `defaultOpen`, `onOpenChange` | | | Open state | -| `side`, `align`, `sideOffset`, … | | | Radix `Tooltip.Content` props | +| `side` | `"top" \| "bottom" \| "start" \| "end"` | | Side it opens on; `start` and `end` follow the reading direction | +| `align`, `sideOffset`, … | | | Radix `Tooltip.Content` props | | `className` | `string` | | Classes for the tip | ## Accessibility @@ -57,4 +58,4 @@ Tapping elsewhere or pressing Escape closes it. ## RTL and Dhivehi -The "More information" label is translated, and `side="left"` / `"right"` follow Radix's physical sides; prefer `top` / `bottom`. +The "More information" label is translated, and `side="start"` / `"end"` follow the reading direction. diff --git a/docs/requirements.md b/docs/requirements.md index 3c03366d3..6307e2c3a 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -5,8 +5,9 @@ sidebar_position: 1.1 This package requires: -- [React](https://react.dev) 18.3+ or 19 +- [React](https://react.dev) 19 - [Tailwind CSS](https://tailwindcss.com) 4 +- [React Hook Form](https://react-hook-form.com) 7 and [TanStack Query](https://tanstack.com/query) 5, which share React context with your app Onu supports every browser Tailwind CSS 4 supports: Chrome and Edge 111+, Safari 16.4+, Firefox 128+, and their mobile versions. diff --git a/figma/plugin/code.js b/figma/plugin/code.js index 29d120b55..749f18d66 100644 --- a/figma/plugin/code.js +++ b/figma/plugin/code.js @@ -2,7 +2,7 @@ (function() { //#region ../packages/onu/package.json - var version = "0.0.0"; + var version = "0.1.0"; //#endregion //#region ../tokens.json @@ -175,6 +175,11 @@ "$value": "oklch(0.97 0 0)", "$description": "Ant colorBgLayout #f5f5f5: the grey page behind dashboard cards." }, + "overlay": { + "$type": "color", + "$value": "oklch(0 0 0 / 10%)", + "$description": "The dim behind modal dialogs, sheets and drawers." + }, "preset-blue": { "$type": "color", "$value": "oklch(0.96 0.021 241.3)", @@ -420,6 +425,11 @@ "$value": "oklch(0.191 0 0)", "$description": "Ant dark colorBgLayout #141414." }, + "overlay": { + "$type": "color", + "$value": "oklch(0 0 0 / 10%)", + "$description": "The dim behind modal dialogs, sheets and drawers." + }, "preset-blue": { "$type": "color", "$value": "oklch(0.219 0.038 263.3)" diff --git a/figma/tokens-studio.json b/figma/tokens-studio.json index 7e6c3663f..6c04e95b0 100644 --- a/figma/tokens-studio.json +++ b/figma/tokens-studio.json @@ -146,6 +146,10 @@ "value": "oklch(0.97 0 0)", "type": "color" }, + "overlay": { + "value": "oklch(0 0 0 / 10%)", + "type": "color" + }, "preset-blue": { "value": "oklch(0.96 0.021 241.3)", "type": "color" @@ -376,6 +380,10 @@ "value": "oklch(0.191 0 0)", "type": "color" }, + "overlay": { + "value": "oklch(0 0 0 / 10%)", + "type": "color" + }, "preset-blue": { "value": "oklch(0.219 0.038 263.3)", "type": "color" diff --git a/packages/onu/README.md b/packages/onu/README.md index 908171131..d5a265037 100644 --- a/packages/onu/README.md +++ b/packages/onu/README.md @@ -16,8 +16,8 @@ Every component and the sample pages: [onu.on-forge.com](https://onu.on-forge.co ## Install ```bash -npm install @javaabu/onu -# optional font pack (Inter, JetBrains Mono, Noto Sans Thaana; Faruma and MV Faseyha are bundled) +npm install @javaabu/onu react-hook-form @tanstack/react-query +# optional font pack (Inter, JetBrains Mono, Noto Sans Thaana; Faruma and MV Faseyha come with tailwind.css and styles.css) npm install @fontsource-variable/inter @fontsource-variable/jetbrains-mono @fontsource-variable/noto-sans-thaana ``` diff --git a/packages/onu/package.json b/packages/onu/package.json index 64884fc44..abcbaf5bc 100644 --- a/packages/onu/package.json +++ b/packages/onu/package.json @@ -124,11 +124,9 @@ "@fullcalendar/timegrid": "^6.1.21", "@googlemaps/markerclusterer": "^2.6.2", "@iconify/react": "^6.0.2", + "@tanstack/react-query": "^5.104.1", "@tiptap/core": "^3.31.4", - "@tiptap/extension-code-block-lowlight": "^3.31.4", - "@tiptap/extension-drag-handle-react": "^3.31.4", "@tiptap/extension-image": "^3.31.4", - "@tiptap/extension-link": "^3.31.4", "@tiptap/extension-placeholder": "^3.31.4", "@tiptap/extension-table": "^3.31.4", "@tiptap/extension-text-align": "^3.31.4", @@ -140,11 +138,11 @@ "@turf/turf": "^7.4.0", "@vis.gl/react-google-maps": "^1.10.1", "barcode-detector": "^3.2.2", - "lowlight": "^3.3.0", "media-chrome": "^4.19.3", - "react": "^18.3.0 || ^19.0.0", - "react-dom": "^18.3.0 || ^19.0.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", "react-easy-crop": "^6.2.4", + "react-hook-form": "^7.89.0", "recharts": "^3.10.1", "shiki": "^4.5.0", "signature_pad": "^5.1.4", @@ -180,14 +178,12 @@ "@iconify/react": "^6.0.2", "@size-limit/preset-small-lib": "^14.1.0", "@tailwindcss/vite": "^4.3.3", + "@tanstack/react-query": "^5.104.1", "@testing-library/jest-dom": "^7.0.1", "@testing-library/react": "^16.3.3", "@testing-library/user-event": "^14.6.7", "@tiptap/core": "^3.31.4", - "@tiptap/extension-code-block-lowlight": "^3.31.4", - "@tiptap/extension-drag-handle-react": "^3.31.4", "@tiptap/extension-image": "^3.31.4", - "@tiptap/extension-link": "^3.31.4", "@tiptap/extension-placeholder": "^3.31.4", "@tiptap/extension-table": "^3.31.4", "@tiptap/extension-text-align": "^3.31.4", @@ -207,13 +203,13 @@ "axe-core": "^4.14.0", "barcode-detector": "^3.2.2", "jsdom": "^30.1.2", - "lowlight": "^3.3.0", "media-chrome": "^4.19.3", "react": "^19.3.0", "react-dom": "^19.3.0", "react-easy-crop": "^6.2.4", + "react-hook-form": "^7.89.0", "recharts": "^3.10.1", - "shadcn": "^4.21.4", + "shadcn": "^4.23.0", "shiki": "^4.5.0", "signature_pad": "^5.1.4", "size-limit": "^14.1.0", @@ -228,13 +224,11 @@ "yet-another-react-lightbox": "^3.32.2" }, "dependencies": { - "@base-ui/react": "^1.8.0", "@dnd-kit/dom": "^0.5.0", "@dnd-kit/helpers": "^0.5.0", "@dnd-kit/react": "^0.5.0", "@internationalized/date": "^3.12.4", "@sindresorhus/slugify": "^3.0.1", - "@tanstack/react-query": "^5.104.1", "@tanstack/react-table": "^9.2.6", "@tanstack/react-virtual": "^3.14.13", "class-variance-authority": "^0.7.1", @@ -247,7 +241,6 @@ "radix-ui": "^1.7.0", "react-aria-components": "^1.21.1", "react-day-picker": "^10.0.2", - "react-hook-form": "^7.89.0", "react-phone-number-input": "^3.4.18", "sonner": "^2.0.8", "tailwind-merge": "^3.7.0", @@ -280,27 +273,15 @@ "@tiptap/extension-text-align": { "optional": true }, - "@tiptap/extension-link": { - "optional": true - }, "@tiptap/extension-image": { "optional": true }, "@tiptap/extension-table": { "optional": true }, - "@tiptap/extension-drag-handle-react": { - "optional": true - }, "@tiptap/core": { "optional": true }, - "@tiptap/extension-code-block-lowlight": { - "optional": true - }, - "lowlight": { - "optional": true - }, "shiki": { "optional": true }, @@ -451,7 +432,7 @@ "name": "primitives", "path": "dist/ui/index.js", "import": "*", - "limit": "165 kB" + "limit": "130 kB" }, { "name": "thaana", @@ -518,6 +499,7 @@ "path": [ "src/styles/styles.css", "src/styles/tokens.css", + "src/styles/fonts-thaana.css", "src/styles/theme.css" ], "rolldown": false, @@ -529,17 +511,24 @@ "src/styles/tailwind.css", "src/styles/shadcn.css", "src/styles/tokens.css", + "src/styles/fonts-thaana.css", "src/styles/theme.css", "src/styles/base.css" ], "rolldown": false, "limit": "8.5 kB" }, + { + "name": "fonts-thaana.css", + "path": "src/styles/fonts-thaana.css", + "rolldown": false, + "limit": "400 B" + }, { "name": "fonts.css", "path": "src/styles/fonts.css", "rolldown": false, - "limit": "600 B" + "limit": "400 B" }, { "name": "fonts-optional.css", diff --git a/packages/onu/src/charts/_shared.tsx b/packages/onu/src/charts/_shared.tsx index 78bb229c4..4c7449a7d 100644 --- a/packages/onu/src/charts/_shared.tsx +++ b/packages/onu/src/charts/_shared.tsx @@ -299,7 +299,7 @@ export function ChartFrame({ type="button" variant="outline" size="sm" - data-slot="chart-view-toggle" + data-part="chart-view-toggle" aria-controls={bodyId} className={classNames?.toggle} onClick={() => setView(view === "table" ? "chart" : "table")} diff --git a/packages/onu/src/charts/time-series-chart.tsx b/packages/onu/src/charts/time-series-chart.tsx index 1fbf3174e..71cd77375 100644 --- a/packages/onu/src/charts/time-series-chart.tsx +++ b/packages/onu/src/charts/time-series-chart.tsx @@ -312,7 +312,7 @@ export function TimeSeriesChart({ <> {actions} {brush && zoomed ? ( - diff --git a/packages/onu/src/components/choice/account-switcher.tsx b/packages/onu/src/components/choice/account-switcher.tsx index 8a314d570..f998f1383 100644 --- a/packages/onu/src/components/choice/account-switcher.tsx +++ b/packages/onu/src/components/choice/account-switcher.tsx @@ -187,7 +187,7 @@ export function AccountSwitcher({ type="button" data-slot="account-switcher" data-size={size} - aria-label={current ? `${t("choice.switchAccount")}: ${current.name}` : undefined} + aria-label={current ? t("choice.switchAccountFrom", { name: current.name }) : undefined} {...props} className={cn( "gap-2.5 p-1.5 pe-2.5 text-sm min-w-0 touch:min-h-11 data-[size=lg]:p-2 flex w-full items-center rounded-lg text-start outline-none hover:bg-muted focus-visible:ring-3 focus-visible:ring-ring/50 aria-expanded:bg-muted", diff --git a/packages/onu/src/components/choice/api-options.ts b/packages/onu/src/components/choice/api-options.ts index e7fe0bcfd..7225b9191 100644 --- a/packages/onu/src/components/choice/api-options.ts +++ b/packages/onu/src/components/choice/api-options.ts @@ -3,6 +3,7 @@ import * as React from "react" import { QueryClient, QueryClientContext } from "@tanstack/react-query" +import { useOnu } from "../../providers/onu-provider" import type { ChoiceOption } from "./options" /* @@ -245,27 +246,28 @@ export function apiOptionsQueryKey(url?: string) { return url ? (["onu-api-options", url] as const) : (["onu-api-options"] as const) } -let fallbackClient: QueryClient | undefined +function fallbackClient(store: { client?: QueryClient }) { + store.client ??= new QueryClient({ defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false } } }) + return store.client +} /** - * Onu's own QueryClient, used when the app has no `QueryClientProvider`, so API - * selects work out of the box. Apps with a provider share their client instead. + * The app's QueryClient if there is a `QueryClientProvider`, else one owned by the + * nearest `OnuProvider` (or this component, without one), so API selects work out of + * the box and a server never shares a cache between requests. */ -export function getOnuQueryClient() { - fallbackClient ??= new QueryClient({ defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false } } }) - return fallbackClient -} - -/** The app's QueryClient if there is a provider, else Onu's fallback client. */ export function useOnuQueryClient(explicit?: QueryClient) { const context = React.useContext(QueryClientContext) - return explicit ?? context ?? getOnuQueryClient() + const [own] = React.useState<{ client?: QueryClient }>({}) + const store = useOnu().queryClientStore ?? own + return explicit ?? context ?? fallbackClient(store) } /** * Clears cached API options (e.g. after creating a record elsewhere) so open - * selects refetch. Pass a URL to limit it to one endpoint. + * selects refetch. Pass a URL to limit it to one endpoint; `useOnuQueryClient()` + * returns the client `ApiSelect` uses. */ -export function invalidateApiOptions(url?: string, client: QueryClient = getOnuQueryClient()) { +export function invalidateApiOptions(client: QueryClient, url?: string) { return client.invalidateQueries({ queryKey: apiOptionsQueryKey(url) }) } diff --git a/packages/onu/src/components/choice/api-select.test.tsx b/packages/onu/src/components/choice/api-select.test.tsx index 2c41ac599..510ee50d6 100644 --- a/packages/onu/src/components/choice/api-select.test.tsx +++ b/packages/onu/src/components/choice/api-select.test.tsx @@ -9,8 +9,8 @@ import { FormField } from "../form/form-field" import { buildUrl, detectHasMore, - getOnuQueryClient, invalidateApiOptions, + useOnuQueryClient, parseApiItem, parseApiResponse, valueUrlFor, @@ -335,13 +335,34 @@ describe("ApiSelect", () => { expect(fetchMock).toHaveBeenCalledTimes(1) }) + it("gives each OnuProvider its own fallback client, and invalidates it", async () => { + const clients: QueryClient[] = [] + function Capture({ children }: { children?: React.ReactNode }) { + const client = useOnuQueryClient() + React.useEffect(() => void clients.push(client), [client]) + return children + } + renderWithOnu() + mockFetch(() => ({ data: islands })) + renderWithOnu( + + + , + ) + expect(clients).toHaveLength(2) + expect(clients[0]).not.toBe(clients[1]) + + await open() + expect(await screen.findByRole("option", { name: "Malé" })).toBeInTheDocument() + expect(clients[1]?.getQueryCache().getAll().length).toBeGreaterThan(0) + await invalidateApiOptions(clients[1]!, "/api/fallback-own") + }) + it("works without a QueryClientProvider (fallback client) and with one", async () => { mockFetch(() => ({ data: islands })) const { unmount } = renderWithOnu() await open() expect(await screen.findByRole("option", { name: "Malé" })).toBeInTheDocument() - expect(getOnuQueryClient().getQueryCache().getAll().length).toBeGreaterThan(0) - await invalidateApiOptions("/api/fallback") unmount() const appClient = newClient() diff --git a/packages/onu/src/components/choice/segmented-control.test.tsx b/packages/onu/src/components/choice/segmented-control.test.tsx index 601b94352..804a35f11 100644 --- a/packages/onu/src/components/choice/segmented-control.test.tsx +++ b/packages/onu/src/components/choice/segmented-control.test.tsx @@ -41,6 +41,16 @@ describe("SegmentedControl", () => { expect(screen.getByRole("radio", { name: "Week" })).toHaveAttribute("aria-checked", "true") }) + it("submits its value under its own name, ahead of the field's", () => { + const { container } = renderWithOnu( + + + , + ) + expect(container.querySelector("input[type=hidden]")).toHaveAttribute("name", "from_prop") + expect(container.querySelector("input[type=hidden]")).toHaveValue("grid") + }) + it("is controlled, icon-only, sized and full width", () => { function Controlled() { const [value, setValue] = React.useState("grid") diff --git a/packages/onu/src/components/choice/segmented-control.tsx b/packages/onu/src/components/choice/segmented-control.tsx index 4a03aa5c0..402a8483f 100644 --- a/packages/onu/src/components/choice/segmented-control.tsx +++ b/packages/onu/src/components/choice/segmented-control.tsx @@ -41,6 +41,8 @@ export interface SegmentedControlProps "type" | "value" | "defaultValue" | "onValueChange" | "children" >, Omit, "fullWidth"> { + /** Field name; adds a hidden input so the value is submitted with a form. */ + name?: string options: ReadonlyArray value?: string defaultValue?: string diff --git a/packages/onu/src/components/date/_calendar-header.tsx b/packages/onu/src/components/date/_calendar-header.tsx index 6363d5a20..19b7706eb 100644 --- a/packages/onu/src/components/date/_calendar-header.tsx +++ b/packages/onu/src/components/date/_calendar-header.tsx @@ -61,7 +61,7 @@ export function CalendarHeader({ aria-label={onCaptionClick ? captionLabel : undefined} aria-disabled={onCaptionClick ? undefined : true} onClick={onCaptionClick} - data-slot="calendar-caption" + data-part="calendar-caption" > {caption} diff --git a/packages/onu/src/components/date/date-calendar.test.tsx b/packages/onu/src/components/date/date-calendar.test.tsx index ef5a13f77..a5e7a477a 100644 --- a/packages/onu/src/components/date/date-calendar.test.tsx +++ b/packages/onu/src/components/date/date-calendar.test.tsx @@ -12,7 +12,7 @@ vi.setConfig({ testTimeout: 30_000 }) const TODAY = new Date(2026, 2, 12) // Thursday 12 March 2026 -const caption = () => document.querySelector("[data-slot=calendar-caption]")! +const caption = () => document.querySelector("[data-part=calendar-caption]")! const dayButton = (day: number, month = "March", year = 2026) => screen.getByRole("button", { name: new RegExp(`${month} ${day}(st|nd|rd|th), ${year}$`) }) diff --git a/packages/onu/src/components/date/date-time-picker.tsx b/packages/onu/src/components/date/date-time-picker.tsx index 6022c195a..6991ddd82 100644 --- a/packages/onu/src/components/date/date-time-picker.tsx +++ b/packages/onu/src/components/date/date-time-picker.tsx @@ -130,7 +130,7 @@ export function DateTimePicker({ variant="secondary" size="sm" className="touch:h-11 rounded-full" - data-slot="time-chip" + data-part="time-chip" aria-label={t("date.changeTime", { time: timeText })} onClick={() => setView("time")} > diff --git a/packages/onu/src/components/display/_utils.ts b/packages/onu/src/components/display/_utils.ts index 95261fb70..5b3eecaa9 100644 --- a/packages/onu/src/components/display/_utils.ts +++ b/packages/onu/src/components/display/_utils.ts @@ -14,10 +14,13 @@ export function toDate(value: DateInput): Date | null { /** * The current time, refreshed every `interval` ms (pass `null` to stop). - * Starts from `initial` so server and first client render agree. + * Starts from `initial`, or `null` until the first tick on mount, so server and + * first client render agree; show a stable placeholder while it is `null`. */ -export function useNow(interval: number | null, initial?: number): number { - const [now, setNow] = React.useState(() => initial ?? Date.now()) +export function useNow(interval: number | null, initial?: number): number | null { + const [now, setNow] = React.useState(initial ?? null) + // eslint-disable-next-line react-hooks/set-state-in-effect -- the clock only exists after hydration + React.useEffect(() => setNow(Date.now()), []) React.useEffect(() => { if (interval === null) return const id = window.setInterval(() => setNow(Date.now()), interval) diff --git a/packages/onu/src/components/display/callout.tsx b/packages/onu/src/components/display/callout.tsx index af65fdac4..858a9a173 100644 --- a/packages/onu/src/components/display/callout.tsx +++ b/packages/onu/src/components/display/callout.tsx @@ -182,7 +182,7 @@ export function Callout({ type="button" variant="ghost" size="icon-xs" - data-slot="callout-dismiss" + data-part="callout-dismiss" onClick={dismiss} aria-label={t("display.dismiss")} className={cn("top-2 end-2 touch:size-11 touch:top-0 touch:end-0 absolute", classNames?.dismiss)} diff --git a/packages/onu/src/components/display/countdown.tsx b/packages/onu/src/components/display/countdown.tsx index d0ef25b45..4990fe9da 100644 --- a/packages/onu/src/components/display/countdown.tsx +++ b/packages/onu/src/components/display/countdown.tsx @@ -106,10 +106,11 @@ export function Countdown({ const target = toDate(to) const [done, setDone] = React.useState(false) const now = useNow(target && !done ? 1000 : null) - const parts = target ? getCountdownParts(target, now) : null - const finished = !!parts && parts.total === 0 + // Until the first tick the clock shows zeros, on the server and in the first client render. + const parts = target ? getCountdownParts(target, now ?? target.getTime()) : null + const finished = now !== null && !!parts && parts.total === 0 if (finished && !done) setDone(true) - if (!finished && done && parts) setDone(false) + if (!finished && done && parts && now !== null) setDone(false) const completeRef = React.useRef(onComplete) React.useEffect(() => { diff --git a/packages/onu/src/components/display/description-list.tsx b/packages/onu/src/components/display/description-list.tsx index dcab3f373..f418570af 100644 --- a/packages/onu/src/components/display/description-list.tsx +++ b/packages/onu/src/components/display/description-list.tsx @@ -158,7 +158,7 @@ export function DescriptionListItem({ type="button" variant="ghost" size="icon-xs" - data-slot="description-list-copy" + data-part="description-list-copy" className={cn("-my-0.5 touch:size-9 shrink-0", classNames?.copy)} aria-label={copied ? t("common.copied") : t("display.copyValue", { label: plainText(label) || text })} onClick={() => void copy(text)} diff --git a/packages/onu/src/components/display/first-render.test.tsx b/packages/onu/src/components/display/first-render.test.tsx new file mode 100644 index 000000000..e82c18a81 --- /dev/null +++ b/packages/onu/src/components/display/first-render.test.tsx @@ -0,0 +1,41 @@ +import { renderToString } from "react-dom/server" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" + +import { OnuProvider } from "../../providers/onu-provider" +import { SidebarMenuSkeleton } from "../../ui/sidebar" +import { Timeline } from "../layout/timeline" +import { Countdown } from "./countdown" +import { PublishBox } from "./publish-box" +import { RelativeDate } from "./relative-date" + +/** The server's markup at two different times; a hydration mismatch shows up as a difference. */ +function serverRenderTwice(ui: React.ReactElement) { + const render = () => renderToString({ui}) + vi.setSystemTime(new Date("2026-01-01T00:00:00Z")) + const first = render() + vi.setSystemTime(new Date("2026-06-15T12:34:56Z")) + return [first, render()] +} + +describe("first render doesn't depend on the clock", () => { + beforeEach(() => vi.useFakeTimers()) + afterEach(() => vi.useRealTimers()) + + it.each([ + ["Countdown", ], + ["RelativeDate", ], + ["PublishBox", ], + ["Timeline", ], + ])("%s", (_name, ui) => { + const [first, second] = serverRenderTwice(ui) + expect(first).toBe(second) + }) + + it("SidebarMenuSkeleton has a width from a fixed cycle", () => { + const widths = [0, 1, 2, 3, 4].map((index) => renderToString()) + expect(widths[0]).toContain("--skeleton-width:60%") + expect(widths[1]).toContain("--skeleton-width:75%") + expect(widths[4]).toBe(widths[0]) + expect(renderToString()).toBe(widths[2]) + }) +}) diff --git a/packages/onu/src/components/display/publish-box.tsx b/packages/onu/src/components/display/publish-box.tsx index 8e1997cce..c449cf516 100644 --- a/packages/onu/src/components/display/publish-box.tsx +++ b/packages/onu/src/components/display/publish-box.tsx @@ -10,7 +10,7 @@ import { Button } from "../../ui/button" import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "../../ui/card" import { Input } from "../../ui/input" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../ui/select" -import { toDate, type DateInput } from "./_utils" +import { toDate, useNow, type DateInput } from "./_utils" import { StatusBadge, type StatusMap } from "./status-badge" import { AppLink } from "./_link" import { registerEnglish } from "../../i18n/english" @@ -110,8 +110,8 @@ export function PublishBox({ const [running, setRunning] = React.useState<"draft" | "publish" | null>(null) const busy = busyProp ?? running const date = toDate(publishAt) - const [nowAtMount] = React.useState(() => Date.now()) - const reference = toDate(now)?.getTime() ?? nowAtMount + const live = useNow(null) + const reference = toDate(now)?.getTime() ?? live const statusLabel = (value: string) => (STATUS_LABELS[value] ? t(STATUS_LABELS[value]) : value) const map: StatusMap = Object.fromEntries( @@ -127,7 +127,7 @@ export function PublishBox({ ] const published = status === "published" - const future = !!date && date.getTime() > reference + const future = !!date && reference !== null && date.getTime() > reference const primaryLabel = published ? t("display.update") : future ? t("display.schedule") : t("display.publish") const run = async (kind: "draft" | "publish", action?: () => void | Promise) => { diff --git a/packages/onu/src/components/display/relative-date.tsx b/packages/onu/src/components/display/relative-date.tsx index 21339a6e6..b73925e66 100644 --- a/packages/onu/src/components/display/relative-date.tsx +++ b/packages/onu/src/components/display/relative-date.tsx @@ -79,7 +79,9 @@ export function RelativeDate({ const now = fixedNow?.getTime() ?? liveNow const nextInterval = - date && !fixedNow && updateInterval !== false ? (updateInterval ?? autoUpdateInterval(date, now)) : null + date && !fixedNow && updateInterval !== false + ? (updateInterval ?? autoUpdateInterval(date, now ?? date.getTime())) + : null if (nextInterval !== interval) setIntervalMs(nextInterval) if (!date) { @@ -95,8 +97,9 @@ export function RelativeDate({ ) } - const relative = formatRelativeDate(date, now, locale, t) const exact = formatDate(date, format, { locale: locale.dateFns }) + // Until the first tick there is no "now": show the exact date, on the server and in the first client render. + const relative = now === null ? exact : formatRelativeDate(date, now, locale, t) if (!showExact) { return ( diff --git a/packages/onu/src/components/display/toast.test.tsx b/packages/onu/src/components/display/toast.test.tsx index 5f0c57614..ad6dd9171 100644 --- a/packages/onu/src/components/display/toast.test.tsx +++ b/packages/onu/src/components/display/toast.test.tsx @@ -1,8 +1,10 @@ import * as React from "react" -import { act, screen } from "@testing-library/react" -import { afterEach, describe, expect, it } from "vitest" +import { act, screen, waitFor } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import { afterEach, describe, expect, it, vi } from "vitest" -import { directions, renderWithOnu } from "../../../test/utils" +import { directions, expectNoA11yViolations, renderWithOnu } from "../../../test/utils" +import { common as dv_common } from "../../locales/dv/common" import { ThemeProvider } from "../../providers/theme-provider" import { resolveToastPosition, toast, Toaster } from "./toast" @@ -19,6 +21,43 @@ describe("resolveToastPosition", () => { }) }) +describe.each(directions)("Toaster + toast in Dhivehi, %s", (dir) => { + afterEach(() => { + act(() => { + toast.dismiss() + }) + }) + + it("has no axe violations, and a close button big enough to touch", async () => { + const { container } = renderWithOnu(, { locale: "dv", dir }) + act(() => { + toast("ރައްކާކުރެވިއްޖެ") + }) + const close = await screen.findByRole("button", { name: dv_common.close }) + expect(close).toHaveClass("size-6!", "touch:size-11!") + await expectNoA11yViolations(container) + }) + + it("dismisses from the close button", async () => { + renderWithOnu(, { locale: "dv", dir }) + act(() => { + toast("ރައްކާކުރެވިއްޖެ") + }) + await userEvent.click(await screen.findByRole("button", { name: dv_common.close })) + await waitFor(() => expect(screen.queryByText("ރައްކާކުރެވިއްޖެ")).not.toBeInTheDocument()) + }) + + it("runs the action button", async () => { + const onClick = vi.fn() + renderWithOnu(, { locale: "dv", dir }) + act(() => { + toast("ރައްކާކުރެވިއްޖެ", { action: { label: "ބަދަލުކުރޭ", onClick } }) + }) + await userEvent.click(await screen.findByRole("button", { name: "ބަދަލުކުރޭ" })) + expect(onClick).toHaveBeenCalledOnce() + }) +}) + describe("Toaster + toast", () => { afterEach(() => { act(() => { diff --git a/packages/onu/src/components/display/toast.tsx b/packages/onu/src/components/display/toast.tsx index 35b7db825..90b4e823a 100644 --- a/packages/onu/src/components/display/toast.tsx +++ b/packages/onu/src/components/display/toast.tsx @@ -73,6 +73,8 @@ export function Toaster({ position = "bottom-end", theme, className, toastOption classNames: { toast: cn("font-sans", locale.code === "dv" && "font-thaana"), description: "text-muted-foreground!", + // Sonner's own rule sets 20×20 px, unlayered, so these need `!` to win. + closeButton: "size-6! touch:size-11!", ...toastOptions?.classNames, }, }} diff --git a/packages/onu/src/components/files/carousel.test.tsx b/packages/onu/src/components/files/carousel.test.tsx index b8446342f..cbbf64e7c 100644 --- a/packages/onu/src/components/files/carousel.test.tsx +++ b/packages/onu/src/components/files/carousel.test.tsx @@ -77,6 +77,34 @@ function setup( afterEach(() => vi.useRealTimers()) +describe("Carousel arrows in RTL", () => { + function Arrows({ orientation }: { orientation: "horizontal" | "vertical" }) { + return ( + + + One + + + + + ) + } + + it("don't mirror when vertical, because the button is rotated", () => { + const { container } = renderWithOnu(, { dir: "rtl" }) + for (const svg of container.querySelectorAll("button svg")) { + expect(svg.getAttribute("class")).not.toContain("rtl:-scale-x-100") + } + }) + + it("mirror when horizontal", () => { + const { container } = renderWithOnu(, { dir: "rtl" }) + for (const svg of container.querySelectorAll("button svg")) { + expect(svg.getAttribute("class")).toContain("rtl:-scale-x-100") + } + }) +}) + describe("Carousel", () => { it("is a labelled region with translated controls, dots and announcements", async () => { const { scrollTo } = setup((setApi) => ( diff --git a/packages/onu/src/components/files/carousel.tsx b/packages/onu/src/components/files/carousel.tsx index 25914a169..3df995cf5 100644 --- a/packages/onu/src/components/files/carousel.tsx +++ b/packages/onu/src/components/files/carousel.tsx @@ -238,7 +238,7 @@ export function CarouselAutoplayToggle({ type="button" variant={variant} size={size} - data-slot="carousel-autoplay-toggle" + data-part="carousel-autoplay-toggle" aria-label={playing ? t("file.pauseSlides") : t("file.playSlides")} className={cn("rounded-full", className)} onClick={() => setPlaying(!playing)} diff --git a/packages/onu/src/components/files/file-input.tsx b/packages/onu/src/components/files/file-input.tsx index f90aa9d34..e11df0208 100644 --- a/packages/onu/src/components/files/file-input.tsx +++ b/packages/onu/src/components/files/file-input.tsx @@ -17,6 +17,7 @@ import { UploadProgress } from "./upload-progress" import { useFormatFileSize, useRejectionMessage, useUploadQueue, type UploadItem, type UploadSource } from "./use-files" import { registerEnglish } from "../../i18n/english" import { file as en_file } from "../../locales/en/file" +import { useIsomorphicLayoutEffect } from "../../hooks/use-isomorphic-layout-effect" registerEnglish({ file: en_file }) @@ -220,7 +221,7 @@ export function FileInput({ // Latest list for upload callbacks; updated as soon as it changes so two uploads finishing together both land. const entriesRef = React.useRef(entries) // Reset on every render, so a change the parent ignored does not linger. - React.useLayoutEffect(() => { + useIsomorphicLayoutEffect(() => { entriesRef.current = entries }) // Upload queue id → value id of the entry it became. diff --git a/packages/onu/src/components/files/use-files.ts b/packages/onu/src/components/files/use-files.ts index d28fdd50a..791691709 100644 --- a/packages/onu/src/components/files/use-files.ts +++ b/packages/onu/src/components/files/use-files.ts @@ -16,6 +16,7 @@ import { } from "./file-utils" import { registerEnglish } from "../../i18n/english" import { file as en_file } from "../../locales/en/file" +import { useIsomorphicLayoutEffect } from "../../hooks/use-isomorphic-layout-effect" registerEnglish({ file: en_file }) @@ -154,7 +155,7 @@ export function useUploadQueue(options: UseUploadQueueOptions) { const changed = React.useEffectEvent((next: UploadItem[]) => onItemsChange?.(next)) // Latest callbacks for async upload results (updated after render, read later in promise callbacks). const callbacks = React.useRef({ onUploaded, onError }) - React.useLayoutEffect(() => { + useIsomorphicLayoutEffect(() => { callbacks.current = { onUploaded, onError } }) diff --git a/packages/onu/src/components/form/inline-edit.tsx b/packages/onu/src/components/form/inline-edit.tsx index f4ab30c31..fd8ffad40 100644 --- a/packages/onu/src/components/form/inline-edit.tsx +++ b/packages/onu/src/components/form/inline-edit.tsx @@ -250,7 +250,7 @@ export function InlineEdit({ data-slot="inline-edit-display" disabled={disabled} onClick={start} - aria-label={`${t("form.edit", { label })}: ${value || t("form.emptyValue")}`} + aria-label={t("form.editValue", { label, value: value || t("form.emptyValue") })} {...(thaana && value ? { lang: "dv", dir: "rtl" } : {})} className={cn( "group/inline-edit gap-2 px-1.5 py-1 min-h-8 touch:min-h-11 inline-flex max-w-full items-center rounded-md text-start outline-none hover:bg-muted focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50", diff --git a/packages/onu/src/components/form/save-bar.tsx b/packages/onu/src/components/form/save-bar.tsx index 5101a5851..18b2dba38 100644 --- a/packages/onu/src/components/form/save-bar.tsx +++ b/packages/onu/src/components/form/save-bar.tsx @@ -13,6 +13,7 @@ import { useUnsavedChangesGuard } from "./unsaved-changes-guard" import { registerEnglish } from "../../i18n/english" import { common as en_common } from "../../locales/en/common" import { form as en_form } from "../../locales/en/form" +import { useIsomorphicLayoutEffect } from "../../hooks/use-isomorphic-layout-effect" registerEnglish({ common: en_common, form: en_form }) @@ -132,7 +133,7 @@ export function describeChanges( * a desktop sidebar. Writes the column's inline offsets to CSS variables. */ function useContentColumnInset(ref: React.RefObject, enabled: boolean) { - React.useLayoutEffect(() => { + useIsomorphicLayoutEffect(() => { const bar = ref.current if (!enabled || !bar || typeof window === "undefined") return const column = bar.closest('[data-slot="app-layout-main"], [data-slot="sidebar-inset"], main') diff --git a/packages/onu/src/components/form/validation-summary.test.tsx b/packages/onu/src/components/form/validation-summary.test.tsx index c51a6dfb1..b9f84c508 100644 --- a/packages/onu/src/components/form/validation-summary.test.tsx +++ b/packages/onu/src/components/form/validation-summary.test.tsx @@ -3,6 +3,7 @@ import userEvent from "@testing-library/user-event" import { describe, expect, it } from "vitest" import { directions, expectNoA11yViolations, renderWithOnu } from "../../../test/utils" +import { form as dv_form } from "../../locales/dv/form" import { TextInput } from "../inputs/text-input" import { FormField } from "./form-field" import { @@ -86,6 +87,14 @@ describe("ValidationSummary", () => { }) }) +describe("ValidationSummary with a ValidationError that has no message", () => { + it("shows the translated title", () => { + const errors = getValidationErrors(new ValidationError({ name: "ނަން ލިޔުއްވާ" })) + renderWithOnu(, { locale: "dv" }) + expect(screen.getByRole("alert")).toHaveTextContent(dv_form.errorSummaryTitle.one) + }) +}) + describe("validation helpers", () => { it("detects Laravel payloads", () => { expect(isValidationPayload(payload)).toBe(true) @@ -105,7 +114,8 @@ describe("validation helpers", () => { expect(getValidationErrors("nope")).toBeNull() const err = new ValidationError(payload) expect(getValidationErrors(err)).toEqual({ message: payload.message, errors: payload.errors }) - expect(new ValidationError({ a: "b" }).message).toBe("The given data was invalid.") + expect(new ValidationError({ a: "b" }).message).toBe("") + expect(getValidationErrors(new ValidationError({ a: "b" }))?.message).toBeUndefined() expect(new ValidationError({ a: "b" }, "Custom").message).toBe("Custom") }) diff --git a/packages/onu/src/components/form/validation.ts b/packages/onu/src/components/form/validation.ts index ef032aed6..a25021842 100644 --- a/packages/onu/src/components/form/validation.ts +++ b/packages/onu/src/components/form/validation.ts @@ -11,7 +11,7 @@ export class ValidationError extends Error { readonly errors: FormErrors constructor(payload: ValidationPayload | FormErrors, message?: string) { const body = isValidationPayload(payload) ? payload : { errors: payload } - super(message ?? body.message ?? "The given data was invalid.") + super(message ?? body.message ?? "") this.name = "ValidationError" this.errors = body.errors } @@ -36,7 +36,7 @@ export function isValidationPayload(value: unknown): value is ValidationPayload * Returns `null` when there is none. Use `readValidationErrors` for a fetch `Response`. */ export function getValidationErrors(value: unknown): ValidationPayload | null { - if (value instanceof ValidationError) return { message: value.message, errors: value.errors } + if (value instanceof ValidationError) return { message: value.message || undefined, errors: value.errors } if (isValidationPayload(value)) return value if (!isRecord(value)) return null for (const key of ["response", "data", "body", "cause"] as const) { diff --git a/packages/onu/src/components/inputs/_shared.tsx b/packages/onu/src/components/inputs/_shared.tsx index 06aba4807..502ac2c28 100644 --- a/packages/onu/src/components/inputs/_shared.tsx +++ b/packages/onu/src/components/inputs/_shared.tsx @@ -96,7 +96,7 @@ export function CopyButton({ value, onCopy, className, disabled, ...props }: Cop <> { max} defaultValue={2} />, ) const end = container.querySelector("[data-slot=input-group-addon][data-align=inline-end]")! - expect(Array.from(end.children).map((el) => el.getAttribute("data-slot") ?? el.textContent)).toEqual([ - "unit", - "max", - "number-input-increment", - ]) + expect( + Array.from(end.children).map( + (el) => el.getAttribute("data-part") ?? el.getAttribute("data-slot") ?? el.textContent, + ), + ).toEqual(["unit", "max", "number-input-increment"]) expect(container.querySelector("[data-slot=input-group-addon][data-align=inline-start]")!.children).toHaveLength(1) }) diff --git a/packages/onu/src/components/inputs/number-input.tsx b/packages/onu/src/components/inputs/number-input.tsx index 6e5206fd0..2ffb6b85a 100644 --- a/packages/onu/src/components/inputs/number-input.tsx +++ b/packages/onu/src/components/inputs/number-input.tsx @@ -323,7 +323,7 @@ export function NumberInput(allProps: NumberInputProps) { size="icon-sm" variant="ghost" tabIndex={-1} - data-slot="number-input-decrement" + data-part="number-input-decrement" aria-label={t("input.decrement")} disabled={inactive} aria-disabled={(value !== null && min !== undefined && value <= min) || undefined} @@ -338,7 +338,7 @@ export function NumberInput(allProps: NumberInputProps) { size="icon-sm" variant="ghost" tabIndex={-1} - data-slot="number-input-increment" + data-part="number-input-increment" aria-label={t("input.increment")} disabled={inactive} aria-disabled={(value !== null && max !== undefined && value >= max) || undefined} diff --git a/packages/onu/src/components/inputs/password-input.tsx b/packages/onu/src/components/inputs/password-input.tsx index 1b620ceb8..73de09b97 100644 --- a/packages/onu/src/components/inputs/password-input.tsx +++ b/packages/onu/src/components/inputs/password-input.tsx @@ -106,7 +106,7 @@ export function PasswordInput({ {toggleable ? ( *]:rounded-lg [&_[data-slot=user-profile]_[data-slot=user-avatar]>*]:font-semibold [&_[data-slot=user-profile]_[data-slot=user-avatar]>*]:text-foreground", - "[&_[data-slot=user-profile]_[data-slot=user-avatar]>*]:bg-[color:var(--shell-weak,#eae7e4)] dark:[&_[data-slot=user-profile]_[data-slot=user-avatar]>*]:bg-[color:var(--shell-weak,#3f3b4c)]", + "[&_[data-part=user-profile]_[data-slot=user-avatar]]:rounded-lg [&_[data-part=user-profile]_[data-slot=user-avatar]>*]:rounded-lg [&_[data-part=user-profile]_[data-slot=user-avatar]>*]:font-semibold [&_[data-part=user-profile]_[data-slot=user-avatar]>*]:text-foreground", + "[&_[data-part=user-profile]_[data-slot=user-avatar]>*]:bg-shell-weak", // The account button: the avatar alone on narrow screens, no chevron. - "max-md:[&_[data-slot=user-profile-text]]:hidden [&_[data-slot=user-profile-chevron]]:hidden max-md:[&_[data-slot=user-profile]]:min-h-9 max-md:[&_[data-slot=user-profile]]:p-0.5", + "max-md:[&_[data-slot=user-profile-text]]:hidden [&_[data-slot=user-profile-chevron]]:hidden max-md:[&_[data-part=user-profile]]:min-h-9 max-md:[&_[data-part=user-profile]]:p-0.5", ].join(" ") export const appHeaderVariants = cva( - "gap-2 px-4 supports-backdrop-filter:backdrop-blur z-30 flex h-[calc(var(--onu-header-height,3.5rem)+env(safe-area-inset-top))] w-full shrink-0 items-center bg-background/95 pt-[env(safe-area-inset-top)] [--onu-header-height:3.5rem] supports-backdrop-filter:bg-background/80 [&_[data-slot=user-profile]]:w-auto", + "gap-2 px-4 supports-backdrop-filter:backdrop-blur z-30 flex h-[calc(var(--onu-header-height,3.5rem)+env(safe-area-inset-top))] w-full shrink-0 items-center bg-background/95 pt-[env(safe-area-inset-top)] [--onu-header-height:3.5rem] supports-backdrop-filter:bg-background/80 [&_[data-part=user-profile]]:w-auto", { variants: { variant: { diff --git a/packages/onu/src/components/layout/app-layout.test.tsx b/packages/onu/src/components/layout/app-layout.test.tsx index 23812ebd8..9b1712e54 100644 --- a/packages/onu/src/components/layout/app-layout.test.tsx +++ b/packages/onu/src/components/layout/app-layout.test.tsx @@ -58,7 +58,7 @@ describe("AppLayout", () => { const onSidebarOpenChange = vi.fn() renderWithOnu() expect(screen.queryByRole("link", { name: "Skip to content" })).toBeNull() - await userEvent.click(document.querySelector("[data-slot=sidebar-trigger]")!) + await userEvent.click(document.querySelector("[data-part=sidebar-trigger]")!) expect(onSidebarOpenChange).toHaveBeenCalledWith(false) }) @@ -102,7 +102,7 @@ describe("AppLayout", () => { it.each(directions)("works in %s with Dhivehi", async (dir) => { renderWithOnu(, { locale: "dv", dir }) expect(screen.getByRole("link", { name: "ކޮންޓެންޓަށް ފުންމާލާ" })).toBeInTheDocument() - expect(document.querySelector("[data-slot=sidebar-trigger]")).toHaveAccessibleName("ސައިޑްބާރު ދައްކާ/ފޮރުވާ") + expect(document.querySelector("[data-part=sidebar-trigger]")).toHaveAccessibleName("ސައިޑްބާރު ދައްކާ/ފޮރުވާ") await expectNoA11yViolations() }) }) diff --git a/packages/onu/src/components/layout/app-sidebar.test.tsx b/packages/onu/src/components/layout/app-sidebar.test.tsx index 6ae0c1683..7441d9cde 100644 --- a/packages/onu/src/components/layout/app-sidebar.test.tsx +++ b/packages/onu/src/components/layout/app-sidebar.test.tsx @@ -189,7 +189,7 @@ describe("AppSidebar", () => { renderSidebar() const sidebar = document.querySelector("[data-slot=sidebar]")! expect(sidebar).toHaveAttribute("data-state", "expanded") - await userEvent.click(document.querySelector("[data-slot=sidebar-trigger]")!) + await userEvent.click(document.querySelector("[data-part=sidebar-trigger]")!) expect(sidebar).toHaveAttribute("data-state", "collapsed") await userEvent.keyboard("{Control>}b{/Control}") expect(sidebar).toHaveAttribute("data-state", "expanded") @@ -198,7 +198,7 @@ describe("AppSidebar", () => { it("focuses the phone drawer itself, so a field at the top doesn't bring up the keyboard", async () => { mockMobile() renderSidebar(} />) - await userEvent.click(document.querySelector("[data-slot=sidebar-trigger]")!) + await userEvent.click(document.querySelector("[data-part=sidebar-trigger]")!) const dialog = await screen.findByRole("dialog", { name: "Sidebar" }) expect(dialog).toHaveFocus() await userEvent.tab() @@ -209,7 +209,7 @@ describe("AppSidebar", () => { mockMobile() renderSidebar() expect(screen.queryByRole("dialog")).toBeNull() - await userEvent.click(document.querySelector("[data-slot=sidebar-trigger]")!) + await userEvent.click(document.querySelector("[data-part=sidebar-trigger]")!) const dialog = await screen.findByRole("dialog", { name: "Sidebar" }) await userEvent.click(within(dialog).getByRole("link", { name: /Applications/ })) expect(screen.queryByRole("dialog")).toBeNull() diff --git a/packages/onu/src/components/layout/app-sidebar.tsx b/packages/onu/src/components/layout/app-sidebar.tsx index 47d5cdf77..153939969 100644 --- a/packages/onu/src/components/layout/app-sidebar.tsx +++ b/packages/onu/src/components/layout/app-sidebar.tsx @@ -143,10 +143,10 @@ function Badge({ item, ctx }: { item: NavItem; ctx: Ctx }) { * active states on the shell, a semibold active label and a brand icon. */ const SHELL_BUTTON = cn( - "h-9 gap-3 px-2.5 text-sm rounded-lg text-[color:var(--shell-foreground,var(--foreground))]", - "hover:text-[color:var(--shell-foreground,var(--foreground))] active:text-[color:var(--shell-foreground,var(--foreground))] data-active:text-[color:var(--shell-foreground,var(--foreground))]", + "h-9 gap-3 px-2.5 text-sm rounded-lg text-shell-foreground", + "hover:text-shell-foreground active:text-shell-foreground data-active:text-shell-foreground", shellClasses.hover, - "active:bg-[color:var(--shell-active,rgb(194_192_190/0.35))] dark:active:bg-[color:var(--shell-active,rgb(91_87_107/0.4))]", + "active:bg-shell-active", shellClasses.active, "data-active:font-semibold [&>svg]:text-muted-foreground data-active:[&>svg]:text-primary", "group-has-data-[sidebar=menu-badge]/menu-item:pe-12", diff --git a/packages/onu/src/components/layout/back-to-top.tsx b/packages/onu/src/components/layout/back-to-top.tsx index 50b63e1f4..77d27662a 100644 --- a/packages/onu/src/components/layout/back-to-top.tsx +++ b/packages/onu/src/components/layout/back-to-top.tsx @@ -79,7 +79,7 @@ export function BackToTop({ const label = t("layout.backToTop") return (