From 400cc53b60b95ef761a0645164da8d2d61a03b34 Mon Sep 17 00:00:00 2001 From: woksin Date: Thu, 16 Jul 2026 11:01:23 +0200 Subject: [PATCH 01/42] WIP: PrimeReact 11 migration checkpoint (Phases 0-4) Preserve in-progress migration of @cratis/components to PrimeReact 11: foundation (ESM-only packaging, @primereact/core provider, @primeuix/themes Storybook theming, tokens), the compositional Dialog stack, all 12 CommandForm fields, and the Select-based Dropdown wrapper. This is an intentional checkpoint commit: the not-yet-migrated widgets (DataTable, SchemaEditor, ObjectContentEditor, TimeMachine, Tooltip, Stepper) still reference removed v10 paths, so `tsc -b` reports 85 errors confined to those files. Remaining phases (5-9) follow in subsequent commits that each restore a clean build. Co-Authored-By: Claude Opus 4.8 (1M context) --- Source/.storybook/preview.js | 105 ++++++------ ...ng_with_close_dialog_and_command_result.ts | 25 ++- .../when_given_initial_valid_values.ts | 20 ++- .../for_CommandDialog/when_not_executing.ts | 24 ++- .../when_validity_is_gated.ts | 27 +++- Source/CommandForm/fields/CalendarField.tsx | 84 +++++++--- Source/CommandForm/fields/CheckboxField.tsx | 26 +-- Source/CommandForm/fields/ChipsField.tsx | 64 +++++--- .../CommandForm/fields/ColorPickerField.tsx | 73 ++++++--- Source/CommandForm/fields/DropdownField.tsx | 2 +- Source/CommandForm/fields/InputTextField.tsx | 3 +- .../CommandForm/fields/MultiSelectField.tsx | 34 ++-- Source/CommandForm/fields/NumberField.tsx | 38 +++-- .../CommandForm/fields/RadioButtonField.tsx | 22 +-- Source/CommandForm/fields/RadioGroupField.tsx | 22 +-- Source/CommandForm/fields/SliderField.tsx | 19 ++- Source/CommandForm/fields/TextAreaField.tsx | 15 +- Source/Common/CratisComponentsProvider.tsx | 38 +++-- Source/Dialogs/BusyIndicatorDialog.tsx | 2 +- Source/Dialogs/Dialog.tsx | 117 ++++++++++---- ...confirming_with_close_dialog_and_result.ts | 25 ++- .../for_Dialog/when_rendered_with_is_busy.ts | 25 ++- Source/Dropdown/Dropdown.tsx | 151 +++++++++++++++--- Source/package.json | 30 +--- Source/rollup.config.mjs | 3 +- Source/scripts/copy-css.sh | 15 +- rollup.config.mjs | 62 +++---- 27 files changed, 687 insertions(+), 384 deletions(-) diff --git a/Source/.storybook/preview.js b/Source/.storybook/preview.js index 8d43d14..e253347 100644 --- a/Source/.storybook/preview.js +++ b/Source/.storybook/preview.js @@ -5,41 +5,56 @@ import { addons } from 'storybook/preview-api'; import React from 'react'; import 'primeicons/primeicons.css'; import './preview.css'; -import darkThemeUrl from 'primereact/resources/themes/lara-dark-blue/theme.css?url'; -import lightThemeUrl from 'primereact/resources/themes/lara-light-blue/theme.css?url'; +import Aura from '@primeuix/themes/aura'; import { CratisComponentsProvider } from '../Common/CratisComponentsProvider'; import { tailwindPtPreset } from './pt-preset'; +// PrimeReact 11 is unstyled-first and token-based: the styled look comes from a +// @primeuix/themes preset applied through the provider's `theme` config (which +// injects the `--p-*` design tokens), and dark mode is toggled with a class the +// preset's `darkModeSelector` targets. There are no theme CSS files to +// anymore (the v10 `primereact/resources/themes/lara-*` files were removed). +const DARK_SELECTOR = 'cratis-dark'; + +const styledTheme = { preset: Aura, options: { darkModeSelector: `.${DARK_SELECTOR}` } }; + +// PrimeReact 11's styled layer (@primeuix/themes) is license-gated: without a valid +// PrimeUI license key the components fall back to unstyled and a nag banner appears. +// Set STORYBOOK_PRIMEUI_LICENSE to preview the styled (Aura) modes with your own key; +// the license-free default below is the unstyled + Tailwind `pt` path. +const PRIMEUI_LICENSE = import.meta.env?.STORYBOOK_PRIMEUI_LICENSE; +const withLicense = (value) => (PRIMEUI_LICENSE ? { ...value, license: PRIMEUI_LICENSE } : value); + const STYLING_MODES = { - 'lara-dark': { - title: 'Path A — Lara Dark Blue', - themeUrl: darkThemeUrl, - bodyClass: null, - providerValue: {}, - }, - 'lara-light': { - title: 'Path A — Lara Light Blue', - themeUrl: lightThemeUrl, - bodyClass: null, - providerValue: {}, - }, - 'cratis-themed': { - title: 'Path B — Themed with custom palette', - themeUrl: darkThemeUrl, - bodyClass: 'cratis-themed', - providerValue: {}, + 'unstyled-pt': { + title: 'Path C — Unstyled + Tailwind pt (default)', + dark: true, + bodyClass: 'cratis-unstyled-pt', + providerValue: { unstyled: true, pt: tailwindPtPreset }, }, 'unstyled-bare': { title: 'Path C — Unstyled (bare structure)', - themeUrl: null, + dark: true, bodyClass: 'cratis-unstyled-bare', providerValue: { unstyled: true }, }, - 'unstyled-pt': { - title: 'Path C — Unstyled + Tailwind pt', - themeUrl: null, - bodyClass: 'cratis-unstyled-pt', - providerValue: { unstyled: true, pt: tailwindPtPreset }, + 'styled-dark': { + title: 'Path A — Styled (Aura Dark) — needs PrimeUI license', + dark: true, + bodyClass: null, + providerValue: withLicense({ ripple: true, theme: styledTheme }), + }, + 'styled-light': { + title: 'Path A — Styled (Aura Light) — needs PrimeUI license', + dark: false, + bodyClass: null, + providerValue: withLicense({ ripple: true, theme: styledTheme }), + }, + 'cratis-themed': { + title: 'Path B — Styled with custom Cratis palette — needs PrimeUI license', + dark: true, + bodyClass: 'cratis-themed', + providerValue: withLicense({ ripple: true, theme: styledTheme }), }, }; @@ -52,10 +67,10 @@ const ALL_BODY_CLASSES = Object.values(STYLING_MODES) let _docsSiteTheme = null; addons.getChannel().on('STORYBOOK_THEME_CHANGE', ({ theme }) => { - _docsSiteTheme = theme === 'light' ? 'lara-light' : 'lara-dark'; + _docsSiteTheme = theme === 'light' ? 'styled-light' : 'styled-dark'; const mode = STYLING_MODES[_docsSiteTheme]; if (mode) { - applyThemeLink(mode.themeUrl); + applyDarkMode(mode.dark); applyBodyClass(mode.bodyClass); } }); @@ -64,7 +79,7 @@ export const globalTypes = { theme: { name: 'Styling', description: 'Which README styling path to render the story under', - defaultValue: 'lara-dark', + defaultValue: 'unstyled-pt', toolbar: { icon: 'paintbrush', items: Object.entries(STYLING_MODES).map(([value, mode]) => ({ @@ -76,25 +91,8 @@ export const globalTypes = { }, }; -function applyThemeLink(href) { - let link = document.getElementById('primereact-theme'); - if (href === null) { - if (link) { - link.remove(); - } - return; - } - if (!link) { - link = document.createElement('link'); - link.id = 'primereact-theme'; - link.rel = 'stylesheet'; - document.head.appendChild(link); - } - // Changed: use getAttribute instead of .href property to avoid triggering HMR - const currentHref = link.getAttribute('href'); - if (currentHref !== href) { - link.setAttribute('href', href); - } +function applyDarkMode(isDark) { + document.documentElement.classList.toggle(DARK_SELECTOR, !!isDark); } function applyBodyClass(className) { @@ -106,15 +104,18 @@ function applyBodyClass(className) { export const decorators = [ (Story, context) => { - const themeKey = _docsSiteTheme ?? context.globals.theme ?? 'lara-dark'; - const mode = STYLING_MODES[themeKey] ?? STYLING_MODES['lara-dark']; + const themeKey = _docsSiteTheme ?? context.globals.theme ?? 'unstyled-pt'; + const mode = STYLING_MODES[themeKey] ?? STYLING_MODES['unstyled-pt']; - applyThemeLink(mode.themeUrl); + applyDarkMode(mode.dark); applyBodyClass(mode.bodyClass); + // Key the provider by the selected mode so switching styling paths fully + // remounts it — this re-initializes the injected `@primeuix/themes` stylesheet + // instead of leaving a stale preset behind when moving to/from unstyled modes. return React.createElement( CratisComponentsProvider, - { value: mode.providerValue }, + { key: themeKey, value: mode.providerValue }, React.createElement(Story) ); }, @@ -133,5 +134,3 @@ export const parameters = { ], }, }; - - diff --git a/Source/CommandDialog/for_CommandDialog/when_confirming_with_close_dialog_and_command_result.ts b/Source/CommandDialog/for_CommandDialog/when_confirming_with_close_dialog_and_command_result.ts index 0abfe71..43229a0 100644 --- a/Source/CommandDialog/for_CommandDialog/when_confirming_with_close_dialog_and_command_result.ts +++ b/Source/CommandDialog/for_CommandDialog/when_confirming_with_close_dialog_and_command_result.ts @@ -16,17 +16,26 @@ const { closeDialog, commandResult } = vi.hoisted(() => ({ }, })); -vi.mock('primereact/dialog', () => ({ - Dialog: (props: { footer?: React.ReactNode; children?: React.ReactNode }) => - React.createElement('div', null, props.footer, props.children), -})); +vi.mock('primereact/dialog', () => { + // PrimeReact 11's Dialog is compositional; each part is a pass-through that + // renders its children so the footer buttons and content reach the markup. + const part = (props: { children?: React.ReactNode }) => React.createElement('div', null, props.children); + return { + Dialog: { + Root: part, Portal: part, Backdrop: part, Positioner: part, Popup: part, + Header: part, Title: part, Close: part, Content: part, Footer: part, + }, + }; +}); vi.mock('primereact/button', () => ({ - Button: (props: { icon?: string; label?: string; onClick?: () => Promise | void; disabled?: boolean }) => { - if (props.icon === 'pi pi-check' && props.onClick) { - props.onClick(); + // PrimeReact 11 Button renders children (the v10 label/icon props are gone); the + // confirm button carries autoFocus, which stands in for the click in this SSR render. + Button: (props: { autoFocus?: boolean; onClick?: () => Promise | void; disabled?: boolean; children?: React.ReactNode }) => { + if (props.autoFocus && props.onClick) { + void props.onClick(); } - return React.createElement('button', { disabled: props.disabled }, props.label); + return React.createElement('button', { disabled: props.disabled }, props.children); }, })); diff --git a/Source/CommandDialog/for_CommandDialog/when_given_initial_valid_values.ts b/Source/CommandDialog/for_CommandDialog/when_given_initial_valid_values.ts index a33da44..59b894f 100644 --- a/Source/CommandDialog/for_CommandDialog/when_given_initial_valid_values.ts +++ b/Source/CommandDialog/for_CommandDialog/when_given_initial_valid_values.ts @@ -6,14 +6,22 @@ import { renderToStaticMarkup } from 'react-dom/server'; import { vi } from 'vitest'; import { CommandDialog } from '../CommandDialog'; -vi.mock('primereact/dialog', () => ({ - Dialog: (props: { footer?: React.ReactNode; children?: React.ReactNode }) => - React.createElement('div', null, props.footer, props.children) -})); +vi.mock('primereact/dialog', () => { + // PrimeReact 11's Dialog is compositional; each part is a pass-through that + // renders its children so the footer buttons and content reach the markup. + const part = (props: { children?: React.ReactNode }) => React.createElement('div', null, props.children); + return { + Dialog: { + Root: part, Portal: part, Backdrop: part, Positioner: part, Popup: part, + Header: part, Title: part, Close: part, Content: part, Footer: part, + }, + }; +}); vi.mock('primereact/button', () => ({ - Button: (props: { label?: string; disabled?: boolean }) => - React.createElement('button', { disabled: props.disabled }, props.label) + // PrimeReact 11 Button renders children (the v10 label/icon props are gone). + Button: (props: { disabled?: boolean; children?: React.ReactNode }) => + React.createElement('button', { disabled: props.disabled }, props.children) })); vi.mock('@cratis/arc.react/dialogs', () => ({ diff --git a/Source/CommandDialog/for_CommandDialog/when_not_executing.ts b/Source/CommandDialog/for_CommandDialog/when_not_executing.ts index 1a4d349..3850022 100644 --- a/Source/CommandDialog/for_CommandDialog/when_not_executing.ts +++ b/Source/CommandDialog/for_CommandDialog/when_not_executing.ts @@ -6,14 +6,23 @@ import { renderToStaticMarkup } from 'react-dom/server'; import { vi } from 'vitest'; import { CommandDialog } from '../CommandDialog'; -vi.mock('primereact/dialog', () => ({ - Dialog: (props: { footer?: React.ReactNode; children?: React.ReactNode }) => - React.createElement('div', null, props.footer, props.children), -})); +vi.mock('primereact/dialog', () => { + // PrimeReact 11's Dialog is compositional; each part is a pass-through that + // renders its children so the footer buttons and content reach the markup. + const part = (props: { children?: React.ReactNode }) => React.createElement('div', null, props.children); + return { + Dialog: { + Root: part, Portal: part, Backdrop: part, Positioner: part, Popup: part, + Header: part, Title: part, Close: part, Content: part, Footer: part, + }, + }; +}); vi.mock('primereact/button', () => ({ - Button: (props: { label?: string; disabled?: boolean; loading?: boolean }) => - React.createElement('button', { disabled: props.disabled, 'data-loading': props.loading }, props.label), + // PrimeReact 11 Button renders children (the v10 label/icon/loading props are gone). + // A busy dialog now disables its confirm button rather than setting a loading flag. + Button: (props: { disabled?: boolean; children?: React.ReactNode }) => + React.createElement('button', { disabled: props.disabled }, props.children), })); vi.mock('@cratis/arc.react/dialogs', () => ({ @@ -52,6 +61,7 @@ describe('when CommandDialog is in its initial state', () => { }); it('should_not_have_buttons_disabled_due_to_busy', () => { - html.should.not.include('data-loading="true"'); + // A busy dialog swaps the confirm icon for a spinner; a non-busy dialog shows none. + html.should.not.include('pi-spinner'); }); }); diff --git a/Source/CommandDialog/for_CommandDialog/when_validity_is_gated.ts b/Source/CommandDialog/for_CommandDialog/when_validity_is_gated.ts index 62e44a1..39079c4 100644 --- a/Source/CommandDialog/for_CommandDialog/when_validity_is_gated.ts +++ b/Source/CommandDialog/for_CommandDialog/when_validity_is_gated.ts @@ -11,17 +11,26 @@ const { commandFormValidity, executeCommand, setCommandValues } = vi.hoisted(() setCommandValues: vi.fn() })); -vi.mock('primereact/dialog', () => ({ - Dialog: (props: { footer?: React.ReactNode; children?: React.ReactNode }) => - React.createElement('div', null, props.footer, props.children), -})); +vi.mock('primereact/dialog', () => { + // PrimeReact 11's Dialog is compositional; each part is a pass-through that + // renders its children so the footer buttons and content reach the markup. + const part = (props: { children?: React.ReactNode }) => React.createElement('div', null, props.children); + return { + Dialog: { + Root: part, Portal: part, Backdrop: part, Positioner: part, Popup: part, + Header: part, Title: part, Close: part, Content: part, Footer: part, + }, + }; +}); vi.mock('primereact/button', () => ({ - Button: (props: { icon?: string; label?: string; onClick?: () => Promise | void; disabled?: boolean }) => { - if (props.icon === 'pi pi-check' && props.onClick && props.disabled !== true) { + // PrimeReact 11 Button renders children (the v10 label/icon props are gone); the + // confirm button carries autoFocus, which stands in for the click in this SSR render. + Button: (props: { autoFocus?: boolean; onClick?: () => Promise | void; disabled?: boolean; children?: React.ReactNode }) => { + if (props.autoFocus && props.onClick && props.disabled !== true) { void props.onClick(); } - return React.createElement('button', { disabled: props.disabled }, props.label); + return React.createElement('button', { disabled: props.disabled }, props.children); }, })); @@ -72,7 +81,9 @@ describe('when CommandDialog validity is gated', () => { }) ); - const getOkButton = (html: string) => html.match(/]*>Ok<\/button>/)?.[0] ?? ''; + // The PrimeReact 11 Button renders its content as children, so the Ok button's + // markup is `` — find it by its label span. + const getOkButton = (html: string) => (html.match(//g) ?? []).find(button => button.includes('>Ok<')) ?? ''; afterEach(() => { commandFormValidity.isValid = true; diff --git a/Source/CommandForm/fields/CalendarField.tsx b/Source/CommandForm/fields/CalendarField.tsx index 8275345..8f5cac2 100644 --- a/Source/CommandForm/fields/CalendarField.tsx +++ b/Source/CommandForm/fields/CalendarField.tsx @@ -2,7 +2,10 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. import { asCommandFormField, WrappedFieldProps } from '@cratis/arc.react/commands'; -import { Calendar, type CalendarProps } from 'primereact/calendar'; +import { DatePicker } from 'primereact/datepicker'; +import { InputText } from 'primereact/inputtext'; +import { Button } from 'primereact/button'; +import type { DatePickerRootProps, DatePickerRootValueChangeEvent } from '@primereact/types/primitive/datepicker'; import React from 'react'; /** @@ -33,13 +36,13 @@ interface CalendarFieldComponentProps extends WrappedFieldProps { /** Extra CSS class name combined with the default `w-full`. */ className?: string; - /** PrimeReact pass-through configuration applied to the underlying Calendar. */ - pt?: CalendarProps['pt']; + /** PrimeReact pass-through configuration applied to the underlying DatePicker. */ + pt?: DatePickerRootProps['pt']; - /** PrimeReact pass-through options applied to the underlying Calendar. */ - ptOptions?: CalendarProps['ptOptions']; + /** PrimeReact pass-through options applied to the underlying DatePicker. */ + ptOptions?: DatePickerRootProps['ptOptions']; - /** When true, disables every base PrimeReact style on the underlying Calendar. */ + /** When true, disables every base PrimeReact style on the underlying DatePicker. */ unstyled?: boolean; } @@ -58,23 +61,58 @@ interface CalendarFieldComponentProps extends WrappedFieldProps { */ export const CalendarField = asCommandFormField( (props) => ( - props.onChange(e.value ?? null)} - onBlur={props.onBlur} - invalid={props.invalid} - placeholder={props.placeholder} - dateFormat={props.dateFormat} - showIcon={props.showIcon} - showTime={props.showTime} - hourFormat={props.hourFormat} - minDate={props.minDate} - maxDate={props.maxDate} - className={props.className ? `w-full ${props.className}` : 'w-full'} - pt={props.pt} - ptOptions={props.ptOptions} - unstyled={props.unstyled} - /> + // PrimeReact 11's DatePicker is compositional: Root owns the date model, Input is + // the text field, and the popup Calendar/Table auto-render the grid. `onBlur` rides + // the wrapping div because React blur bubbles (focusout). +
+ props.onChange(e.value instanceof Date ? e.value : null)} + invalid={props.invalid} + dateFormat={props.dateFormat} + showTime={props.showTime} + hourFormat={props.hourFormat} + minDate={props.minDate} + maxDate={props.maxDate} + pt={props.pt} + ptOptions={props.ptOptions} + unstyled={props.unstyled}> + + {props.showIcon && ( + + + + )} + + + + + + + + + + + + + + + + + + + + + + + + + {props.showTime && } + + + + +
), { defaultValue: null, diff --git a/Source/CommandForm/fields/CheckboxField.tsx b/Source/CommandForm/fields/CheckboxField.tsx index 71c1caa..7078d05 100644 --- a/Source/CommandForm/fields/CheckboxField.tsx +++ b/Source/CommandForm/fields/CheckboxField.tsx @@ -1,7 +1,8 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. -import { Checkbox, type CheckboxProps } from 'primereact/checkbox'; +import { Checkbox } from 'primereact/checkbox'; +import type { CheckboxRootProps, CheckboxRootChangeEvent } from '@primereact/types/primitive/checkbox'; import React from 'react'; import { asCommandFormField, WrappedFieldProps } from '@cratis/arc.react/commands'; @@ -16,10 +17,10 @@ interface CheckboxFieldComponentProps extends WrappedFieldProps { className?: string; /** PrimeReact pass-through configuration applied to the underlying Checkbox. */ - pt?: CheckboxProps['pt']; + pt?: CheckboxRootProps['pt']; /** PrimeReact pass-through options applied to the underlying Checkbox. */ - ptOptions?: CheckboxProps['ptOptions']; + ptOptions?: CheckboxRootProps['ptOptions']; /** When true, disables every base PrimeReact style on the underlying Checkbox. */ unstyled?: boolean; @@ -36,22 +37,27 @@ interface CheckboxFieldComponentProps extends WrappedFieldProps { */ export const CheckboxField = asCommandFormField( (props) => ( -
- + + unstyled={props.unstyled}> + + + + {props.label && }
), { defaultValue: false, - extractValue: (e: { checked: boolean }) => e.checked + extractValue: (e: CheckboxRootChangeEvent) => e.checked } ); diff --git a/Source/CommandForm/fields/ChipsField.tsx b/Source/CommandForm/fields/ChipsField.tsx index 6ba3be4..8dfd633 100644 --- a/Source/CommandForm/fields/ChipsField.tsx +++ b/Source/CommandForm/fields/ChipsField.tsx @@ -2,7 +2,8 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. import { asCommandFormField, WrappedFieldProps } from '@cratis/arc.react/commands'; -import { Chips, type ChipsProps } from 'primereact/chips'; +import { InputTags } from 'primereact/inputtags'; +import type { InputTagsRootProps, InputTagsRootValueChangeEvent } from '@primereact/types/primitive/inputtags'; import React from 'react'; /** @@ -15,7 +16,12 @@ interface ChipsFieldComponentProps extends WrappedFieldProps { /** Maximum number of chips allowed. */ max?: number; - /** Character (or regex source) that splits typed input into multiple chips. */ + /** + * Character (or regex source) that splits typed input into multiple chips. + * + * PrimeReact 11's `InputTags` commits one tag per Enter rather than exposing + * v10 Chips' `separator`; accepted for API compatibility, not applied. + */ separator?: string; /** When true, the current input is committed as a chip on blur. */ @@ -27,13 +33,13 @@ interface ChipsFieldComponentProps extends WrappedFieldProps { /** Extra CSS class name combined with the default `w-full`. */ className?: string; - /** PrimeReact pass-through configuration applied to the underlying Chips. */ - pt?: ChipsProps['pt']; + /** PrimeReact pass-through configuration applied to the underlying InputTags. */ + pt?: InputTagsRootProps['pt']; - /** PrimeReact pass-through options applied to the underlying Chips. */ - ptOptions?: ChipsProps['ptOptions']; + /** PrimeReact pass-through options applied to the underlying InputTags. */ + ptOptions?: InputTagsRootProps['ptOptions']; - /** When true, disables every base PrimeReact style on the underlying Chips. */ + /** When true, disables every base PrimeReact style on the underlying InputTags. */ unstyled?: boolean; } @@ -49,21 +55,35 @@ interface ChipsFieldComponentProps extends WrappedFieldProps { */ export const ChipsField = asCommandFormField( (props) => ( - props.onChange(e.value ?? [])} - onBlur={props.onBlur} - invalid={props.invalid} - placeholder={props.placeholder} - max={props.max} - separator={props.separator} - addOnBlur={props.addOnBlur} - allowDuplicate={props.allowDuplicate} - className={props.className ? `w-full ${props.className}` : 'w-full'} - pt={props.pt} - ptOptions={props.ptOptions} - unstyled={props.unstyled} - /> + // PrimeReact 11's InputTags is compositional with render-prop parts: Items + // renders one node per tag, Control renders the text-entry input. `onBlur` + // rides the wrapping div because React blur bubbles (focusout). +
+ props.onChange(e.value ?? [])} + invalid={props.invalid} + max={props.max} + addOnBlur={props.addOnBlur} + allowDuplicate={props.allowDuplicate} + pt={props.pt} + ptOptions={props.ptOptions} + unstyled={props.unstyled}> + + {({ item, index, remove, itemProps }) => ( + + {item} + + + )} + + + {({ controlProps }) => } + + +
), { defaultValue: [], diff --git a/Source/CommandForm/fields/ColorPickerField.tsx b/Source/CommandForm/fields/ColorPickerField.tsx index 7cbda44..c7140c7 100644 --- a/Source/CommandForm/fields/ColorPickerField.tsx +++ b/Source/CommandForm/fields/ColorPickerField.tsx @@ -2,29 +2,46 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. import { asCommandFormField, WrappedFieldProps } from '@cratis/arc.react/commands'; -import { ColorPicker, type ColorPickerProps } from 'primereact/colorpicker'; +import { InputColor } from 'primereact/inputcolor'; +import { parseColor } from '@primereact/headless/inputcolor'; +import type { InputColorRootProps, InputColorRootChangeEvent } from '@primereact/types/primitive/inputcolor'; import React from 'react'; +/** Parse a bare hex string (no leading `#`) into a color, falling back on invalid input. */ +const toColor = (hex: string, fallback: string) => { + const candidate = typeof hex === 'string' && hex.length > 0 ? hex : fallback; + try { + return parseColor(`#${candidate}`); + } catch { + return parseColor(`#${fallback}`); + } +}; + /** * Component-level props for {@link ColorPickerField}. */ interface ColorPickerFieldComponentProps extends WrappedFieldProps { - /** When true, renders the color picker inline rather than as a popover. */ + /** + * When true, renders the color picker inline rather than as a popover. + * + * PrimeReact 11's InputColor is composed inline (area + sliders + swatch); the + * v10 popover mode is not reproduced here. Accepted for API compatibility. + */ inline?: boolean; /** Initial color shown when the bound property is empty. Defaults to `'000000'`. */ defaultColor?: string; - /** Extra CSS class name forwarded to the underlying ColorPicker. */ + /** Extra CSS class name forwarded to the underlying InputColor. */ className?: string; - /** PrimeReact pass-through configuration applied to the underlying ColorPicker. */ - pt?: ColorPickerProps['pt']; + /** PrimeReact pass-through configuration applied to the underlying InputColor. */ + pt?: InputColorRootProps['pt']; - /** PrimeReact pass-through options applied to the underlying ColorPicker. */ - ptOptions?: ColorPickerProps['ptOptions']; + /** PrimeReact pass-through options applied to the underlying InputColor. */ + ptOptions?: InputColorRootProps['ptOptions']; - /** When true, disables every base PrimeReact style on the underlying ColorPicker. */ + /** When true, disables every base PrimeReact style on the underlying InputColor. */ unstyled?: boolean; } @@ -41,26 +58,34 @@ interface ColorPickerFieldComponentProps extends WrappedFieldProps { export const ColorPickerField = asCommandFormField( (props) => { const defaultColor = props.defaultColor ?? '000000'; - const value = typeof props.value === 'string' && props.value.length > 0 ? props.value : defaultColor; - // PrimeReact's ColorPicker is the one form component that exposes no `invalid` prop, - // so we apply the `p-invalid` state class directly. This is the exact class the - // `invalid` prop emits on the other fields, so the rendered DOM stays consistent — - // it picks up a theme's invalid styling and harmlessly no-ops in unstyled mode. + // InputColor exposes no `invalid` prop, so surface the invalid state as a class on the + // wrapper — the same `p-invalid` token the other fields emit; it no-ops when unstyled. const invalidClass = props.invalid ? 'p-invalid' : undefined; const className = [invalidClass, props.className].filter(Boolean).join(' ') || undefined; return ( - props.onChange(typeof e.value === 'string' ? e.value : '')} - onBlur={props.onBlur} - inline={props.inline} - defaultColor={defaultColor} - className={className} - pt={props.pt} - ptOptions={props.ptOptions} - unstyled={props.unstyled} - /> + // PrimeReact 11's InputColor is compositional (area + hue slider + swatch). + // `onBlur` rides the wrapping div because React blur bubbles (focusout). +
+ props.onChange(e.value.toString('hex').replace('#', ''))} + pt={props.pt} + ptOptions={props.ptOptions} + unstyled={props.unstyled}> + + + + + + + + + + + + +
); }, { diff --git a/Source/CommandForm/fields/DropdownField.tsx b/Source/CommandForm/fields/DropdownField.tsx index 41aa19c..977958d 100644 --- a/Source/CommandForm/fields/DropdownField.tsx +++ b/Source/CommandForm/fields/DropdownField.tsx @@ -1,7 +1,7 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. -import { Dropdown, type DropdownProps } from 'primereact/dropdown'; +import { Dropdown, type DropdownProps } from '../../Dropdown/Dropdown'; import React from 'react'; import { asCommandFormField, WrappedFieldProps } from '@cratis/arc.react/commands'; diff --git a/Source/CommandForm/fields/InputTextField.tsx b/Source/CommandForm/fields/InputTextField.tsx index 7686cae..c7fd481 100644 --- a/Source/CommandForm/fields/InputTextField.tsx +++ b/Source/CommandForm/fields/InputTextField.tsx @@ -1,7 +1,8 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. -import { InputText, type InputTextProps } from 'primereact/inputtext'; +import { InputText } from 'primereact/inputtext'; +import type { InputTextProps } from '@primereact/types/primitive/inputtext'; import React from 'react'; import { asCommandFormField, WrappedFieldProps } from '@cratis/arc.react/commands'; diff --git a/Source/CommandForm/fields/MultiSelectField.tsx b/Source/CommandForm/fields/MultiSelectField.tsx index 60e69f5..8d86763 100644 --- a/Source/CommandForm/fields/MultiSelectField.tsx +++ b/Source/CommandForm/fields/MultiSelectField.tsx @@ -2,7 +2,7 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. import { asCommandFormField, WrappedFieldProps } from '@cratis/arc.react/commands'; -import { MultiSelect, type MultiSelectProps } from 'primereact/multiselect'; +import { Dropdown, type DropdownProps } from '../../Dropdown/Dropdown'; import React from 'react'; /** @@ -21,10 +21,21 @@ interface MultiSelectFieldComponentProps extends WrappedFieldProps( (props) => ( - > + multiple value={props.value} - onChange={(e: { value: Array | undefined }) => props.onChange(e.value ?? [])} + onChange={(e) => props.onChange(e.value ?? [])} onBlur={props.onBlur} options={props.options} optionValue={props.optionValue} optionLabel={props.optionLabel} placeholder={props.placeholder} - display={props.display} - maxSelectedLabels={props.maxSelectedLabels} filter={props.filter} showClear={props.showClear} invalid={props.invalid} diff --git a/Source/CommandForm/fields/NumberField.tsx b/Source/CommandForm/fields/NumberField.tsx index 72f3fce..7e9d931 100644 --- a/Source/CommandForm/fields/NumberField.tsx +++ b/Source/CommandForm/fields/NumberField.tsx @@ -1,7 +1,8 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. -import { InputNumber, type InputNumberProps } from 'primereact/inputnumber'; +import { InputNumber } from 'primereact/inputnumber'; +import type { InputNumberRootProps, InputNumberRootValueChangeEvent } from '@primereact/types/primitive/inputnumber'; import React from 'react'; import { asCommandFormField, WrappedFieldProps } from '@cratis/arc.react/commands'; @@ -25,10 +26,10 @@ interface NumberFieldComponentProps extends WrappedFieldProps { className?: string; /** PrimeReact pass-through configuration applied to the underlying InputNumber. */ - pt?: InputNumberProps['pt']; + pt?: InputNumberRootProps['pt']; /** PrimeReact pass-through options applied to the underlying InputNumber. */ - ptOptions?: InputNumberProps['ptOptions']; + ptOptions?: InputNumberRootProps['ptOptions']; /** When true, disables every base PrimeReact style on the underlying InputNumber. */ unstyled?: boolean; @@ -46,20 +47,23 @@ interface NumberFieldComponentProps extends WrappedFieldProps { */ export const NumberField = asCommandFormField( (props) => ( - props.onChange(e.value ?? 0)} - onBlur={props.onBlur} - invalid={props.invalid} - placeholder={props.placeholder} - min={props.min} - max={props.max} - step={props.step} - className={props.className ? `w-full ${props.className}` : 'w-full'} - pt={props.pt} - ptOptions={props.ptOptions} - unstyled={props.unstyled} - /> + // PrimeReact 11's InputNumber is compositional (Root owns the numeric model, + // Input is the text field). `onBlur` rides on the wrapping div so the + // CommandForm's blur-timed validation still fires from the inner input. +
+ props.onChange(e.value ?? 0)} + invalid={props.invalid} + min={props.min} + max={props.max} + step={props.step} + pt={props.pt} + ptOptions={props.ptOptions} + unstyled={props.unstyled}> + + +
), { defaultValue: 0, diff --git a/Source/CommandForm/fields/RadioButtonField.tsx b/Source/CommandForm/fields/RadioButtonField.tsx index 91d96e9..07d3c5b 100644 --- a/Source/CommandForm/fields/RadioButtonField.tsx +++ b/Source/CommandForm/fields/RadioButtonField.tsx @@ -1,7 +1,8 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. -import { RadioButton, RadioButtonChangeEvent, type RadioButtonProps } from 'primereact/radiobutton'; +import { RadioButton } from 'primereact/radiobutton'; +import type { RadioButtonRootProps, RadioButtonRootChangeEvent } from '@primereact/types/primitive/radiobutton'; import React from 'react'; import { asCommandFormField, WrappedFieldProps } from '@cratis/arc.react/commands'; @@ -22,10 +23,10 @@ interface RadioButtonFieldComponentProps extends WrappedFieldProps( (props) => ( -
- + props.onChange(e.value)} - onBlur={props.onBlur} + onCheckedChange={(e: RadioButtonRootChangeEvent) => { if (e.checked) props.onChange(props.buttonValue); }} invalid={props.invalid} className={props.className} pt={props.pt} ptOptions={props.ptOptions} - unstyled={props.unstyled} - /> + unstyled={props.unstyled}> + + + + {props.label && }
), diff --git a/Source/CommandForm/fields/RadioGroupField.tsx b/Source/CommandForm/fields/RadioGroupField.tsx index 3190395..3ac3fd2 100644 --- a/Source/CommandForm/fields/RadioGroupField.tsx +++ b/Source/CommandForm/fields/RadioGroupField.tsx @@ -1,7 +1,8 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. -import { RadioButton, RadioButtonChangeEvent, type RadioButtonProps } from 'primereact/radiobutton'; +import { RadioButton } from 'primereact/radiobutton'; +import type { RadioButtonRootProps, RadioButtonRootChangeEvent } from '@primereact/types/primitive/radiobutton'; import React from 'react'; import { asCommandFormField, WrappedFieldProps } from '@cratis/arc.react/commands'; @@ -23,9 +24,9 @@ interface RadioGroupFieldComponentProps extends WrappedFieldProps const optValue = option[props.optionValue] as string | number; const optLabel = option[props.optionLabel] as string; return ( -
- + props.onChange(e.value)} - onBlur={props.onBlur} + onCheckedChange={(e: RadioButtonRootChangeEvent) => { if (e.checked) props.onChange(optValue); }} invalid={props.invalid} pt={props.pt} ptOptions={props.ptOptions} - unstyled={props.unstyled} - /> + unstyled={props.unstyled}> + + + +
); diff --git a/Source/CommandForm/fields/SliderField.tsx b/Source/CommandForm/fields/SliderField.tsx index cb27a47..ed404e9 100644 --- a/Source/CommandForm/fields/SliderField.tsx +++ b/Source/CommandForm/fields/SliderField.tsx @@ -1,7 +1,8 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. -import { Slider, type SliderProps } from 'primereact/slider'; +import { Slider } from 'primereact/slider'; +import type { SliderRootProps, SliderRootChangeEvent } from '@primereact/types/primitive/slider'; import React from 'react'; import { asCommandFormField, WrappedFieldProps } from '@cratis/arc.react/commands'; @@ -22,10 +23,10 @@ interface SliderFieldComponentProps extends WrappedFieldProps { className?: string; /** PrimeReact pass-through configuration applied to the underlying Slider. */ - pt?: SliderProps['pt']; + pt?: SliderRootProps['pt']; /** PrimeReact pass-through options applied to the underlying Slider. */ - ptOptions?: SliderProps['ptOptions']; + ptOptions?: SliderRootProps['ptOptions']; /** When true, disables every base PrimeReact style on the underlying Slider. */ unstyled?: boolean; @@ -43,17 +44,21 @@ interface SliderFieldComponentProps extends WrappedFieldProps { export const SliderField = asCommandFormField( (props) => (
- props.onChange(e.value)} + onValueChange={(e: SliderRootChangeEvent) => props.onChange(Array.isArray(e.value) ? (e.value[0] ?? 0) : e.value)} min={props.min ?? 0} max={props.max ?? 100} step={props.step ?? 1} className={props.className ? `w-full ${props.className}` : 'w-full'} pt={props.pt} ptOptions={props.ptOptions} - unstyled={props.unstyled} - /> + unstyled={props.unstyled}> + + + + +
{props.value}
diff --git a/Source/CommandForm/fields/TextAreaField.tsx b/Source/CommandForm/fields/TextAreaField.tsx index c6168da..2759413 100644 --- a/Source/CommandForm/fields/TextAreaField.tsx +++ b/Source/CommandForm/fields/TextAreaField.tsx @@ -1,7 +1,8 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. -import { InputTextarea, type InputTextareaProps } from 'primereact/inputtextarea'; +import { Textarea } from 'primereact/textarea'; +import type { TextareaProps } from '@primereact/types/primitive/textarea'; import React from 'react'; import { asCommandFormField, WrappedFieldProps } from '@cratis/arc.react/commands'; @@ -21,13 +22,13 @@ interface TextAreaFieldComponentProps extends WrappedFieldProps { /** Extra CSS class name combined with the default `w-full`. */ className?: string; - /** PrimeReact pass-through configuration applied to the underlying InputTextarea. */ - pt?: InputTextareaProps['pt']; + /** PrimeReact pass-through configuration applied to the underlying Textarea. */ + pt?: TextareaProps['pt']; - /** PrimeReact pass-through options applied to the underlying InputTextarea. */ - ptOptions?: InputTextareaProps['ptOptions']; + /** PrimeReact pass-through options applied to the underlying Textarea. */ + ptOptions?: TextareaProps['ptOptions']; - /** When true, disables every base PrimeReact style on the underlying InputTextarea. */ + /** When true, disables every base PrimeReact style on the underlying Textarea. */ unstyled?: boolean; } @@ -44,7 +45,7 @@ interface TextAreaFieldComponentProps extends WrappedFieldProps { */ export const TextAreaField = asCommandFormField( (props) => ( - ; +export type CratisComponentsConfig = Partial; export interface CratisComponentsProviderProps { /** @@ -41,15 +42,26 @@ export const mergeCratisComponentsConfig = (value: CratisComponentsConfig | unde merge(cratisDefaults, value ?? {}) as CratisComponentsConfig; /** - * Single setup point for Cratis Components. Wraps {@link PrimeReactProvider} so the - * library can layer Cratis-wide defaults on top of PrimeReact's pass-through and - * unstyled mechanisms while still letting the consumer take complete control: + * Single setup point for Cratis Components. Wraps PrimeReact 11's + * {@link PrimeReactProvider} so the library can layer Cratis-wide defaults on top of + * PrimeReact's pass-through and unstyled mechanisms while still letting the consumer + * take complete control. PrimeReact 11 is unstyled-first, so this library ships no + * bundled theme — you choose the styling posture: * - * - Pass `unstyled: true` to disable every PrimeReact base style. The wrappers in - * this package then render structurally only and pick up all visuals from your - * own CSS, Tailwind, or pt definitions. + * - **Unstyled (default posture):** pass nothing, or `unstyled: true`, and style the + * structural markup yourself through the `--cratis-*` token layer, your own CSS, + * Tailwind, or `pt` definitions. + * - **Styled:** pass `theme={{ preset }}` with a `@primeuix/themes` preset (for example + * `import Aura from '@primeuix/themes/aura'`) to opt into a token-based styled look. * - Pass `pt` / `ptOptions` to apply global per-component pass-through. * + * **PrimeUI license.** PrimeReact 11 is no longer MIT — its provider verifies a PrimeUI + * license on mount and, without one, logs a warning and shows an "Invalid PrimeUI License" + * banner (in development *and* production). Supply your key via `value={{ license: '…' }}` + * (a free Community tier covers individuals, non-profits, non-commercial OSS, and small + * orgs; otherwise a Commercial license is required — see primeui.store). The key flows + * straight through to PrimeReact's provider. + * * Consumers who want to talk to PrimeReact directly may still mount * {@link PrimeReactProvider} themselves — this component is an optional convenience, * not a requirement. @@ -57,5 +69,5 @@ export const mergeCratisComponentsConfig = (value: CratisComponentsConfig | unde export const CratisComponentsProvider = ({ value, children }: CratisComponentsProviderProps) => { const merged = useMemo(() => mergeCratisComponentsConfig(value), [value]); - return {children}; + return {children}; }; diff --git a/Source/Dialogs/BusyIndicatorDialog.tsx b/Source/Dialogs/BusyIndicatorDialog.tsx index d6b052e..c5546dd 100644 --- a/Source/Dialogs/BusyIndicatorDialog.tsx +++ b/Source/Dialogs/BusyIndicatorDialog.tsx @@ -61,7 +61,7 @@ export const BusyIndicatorDialog = (props: BusyIndicatorDialogRequest) => { buttons={null} >
- +

{props.message}

diff --git a/Source/Dialogs/Dialog.tsx b/Source/Dialogs/Dialog.tsx index a021d1c..4775139 100644 --- a/Source/Dialogs/Dialog.tsx +++ b/Source/Dialogs/Dialog.tsx @@ -1,10 +1,11 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. -import { Dialog as PrimeDialog, type DialogProps as PrimeDialogProps } from 'primereact/dialog'; +import { Dialog as PrimeDialog } from 'primereact/dialog'; +import type { DialogRootProps, DialogRootChangeEvent } from '@primereact/types/primitive/dialog'; import { Button } from 'primereact/button'; import { DialogResult, DialogButtons, useDialogContext } from '@cratis/arc.react/dialogs'; -import { ReactNode } from 'react'; +import { CSSProperties, ReactNode } from 'react'; /** * Callback used by {@link Dialog} (and its wrappers) when the dialog is about to @@ -63,13 +64,19 @@ export interface DialogProps { /** Dialog width, any valid CSS length. Defaults to `'450px'`. */ width?: string; - /** Inline style forwarded to the underlying PrimeReact Dialog root. */ - style?: PrimeDialogProps['style']; + /** Inline style forwarded to the dialog popup (the visible dialog box). */ + style?: CSSProperties; /** Inline style forwarded to the dialog's inner content area. */ - contentStyle?: PrimeDialogProps['contentStyle']; + contentStyle?: CSSProperties; - /** When true, allows the user to resize the dialog. Defaults to `false`. */ + /** + * When true, allows the user to resize the dialog. Defaults to `false`. + * + * PrimeReact 11's headless Dialog has no built-in resize handle, so this is + * currently accepted for API compatibility and has no effect. Kept so + * existing call sites continue to type-check. + */ resizable?: boolean; /** @@ -104,17 +111,18 @@ export interface DialogProps { /** * PrimeReact pass-through configuration. Applies to the underlying Dialog's - * slots — see PrimeReact's Dialog `pt` reference for the available keys. - * Use this (or set a global preset on `CratisComponentsProvider`) to take - * full control of styling. + * slots (`root`, `positioner`, `backdrop`, `header`, `title`, `close`, + * `content`, `footer`, …) — see PrimeReact's Dialog `pt` reference for the + * available keys. Use this (or set a global preset on + * `CratisComponentsProvider`) to take full control of styling. */ - pt?: PrimeDialogProps['pt']; + pt?: DialogRootProps['pt']; /** * PrimeReact pass-through options. Controls merge vs. replace behavior for * the {@link pt} preset. */ - ptOptions?: PrimeDialogProps['ptOptions']; + ptOptions?: DialogRootProps['ptOptions']; /** * When true, disables every base PrimeReact style on the underlying Dialog. @@ -263,31 +271,43 @@ export const Dialog = ({ } }; - const okFooter = ( - <> - ); + const okFooter = footerButton(DialogResult.Ok, okLabel, 'pi pi-check', true); + const okCancelFooter = ( <> -
); + // The dialog is dismissable (backdrop click, Escape, header close button) + // only for the predefined-button sets, mirroring the v10 `closable` behavior + // that keyed off `typeof buttons === 'number'`. + const isDismissable = typeof buttons === 'number'; + + // PrimeReact 11's Dialog is a controlled overlay: `open` reflects `visible`, + // and any dismiss gesture fires `onOpenChange` with `value: false`. We route + // that through the same `handleClose(Cancelled)` path used by the footer's + // cancel button, so the "return false keeps the dialog open" contract holds — + // the parent (the Arc dialog host) owns `visible`, so nothing closes unless + // `handleClose` calls back through the host. + const handleOpenChange = (event: DialogRootChangeEvent) => { + if (!event.value) { + handleClose(DialogResult.Cancelled); + } + }; + return ( - handleClose(DialogResult.Cancelled) : () => {}} - visible={visible} - style={{ width, ...style }} - contentStyle={contentStyle} - resizable={resizable} - closable={typeof buttons === 'number'} - className={className} + dismissable={isDismissable} + closeOnEscape={isDismissable} pt={pt} ptOptions={ptOptions} unstyled={unstyled}> - {children} - + + + + + + {headerElement} + {isDismissable && ( + + + + )} + + {children} + {footer} + + + + ); }; diff --git a/Source/Dialogs/for_Dialog/when_confirming_with_close_dialog_and_result.ts b/Source/Dialogs/for_Dialog/when_confirming_with_close_dialog_and_result.ts index b0c8ab1..6e6eba2 100644 --- a/Source/Dialogs/for_Dialog/when_confirming_with_close_dialog_and_result.ts +++ b/Source/Dialogs/for_Dialog/when_confirming_with_close_dialog_and_result.ts @@ -10,17 +10,26 @@ const { closeDialog } = vi.hoisted(() => ({ closeDialog: vi.fn(), })); -vi.mock('primereact/dialog', () => ({ - Dialog: (props: { footer?: React.ReactNode; children?: React.ReactNode }) => - React.createElement('div', null, props.footer, props.children), -})); +vi.mock('primereact/dialog', () => { + // PrimeReact 11's Dialog is compositional; each part is a pass-through that + // renders its children so the footer buttons and content reach the markup. + const part = (props: { children?: React.ReactNode }) => React.createElement('div', null, props.children); + return { + Dialog: { + Root: part, Portal: part, Backdrop: part, Positioner: part, Popup: part, + Header: part, Title: part, Close: part, Content: part, Footer: part, + }, + }; +}); vi.mock('primereact/button', () => ({ - Button: (props: { icon?: string; label?: string; onClick?: () => Promise | void; disabled?: boolean }) => { - if (props.icon === 'pi pi-check' && props.onClick) { - props.onClick(); + // PrimeReact 11 Button renders children (the v10 label/icon props are gone); the + // confirm button carries autoFocus, which stands in for the click in this SSR render. + Button: (props: { autoFocus?: boolean; onClick?: () => Promise | void; disabled?: boolean; children?: React.ReactNode }) => { + if (props.autoFocus && props.onClick) { + void props.onClick(); } - return React.createElement('button', { disabled: props.disabled }, props.label); + return React.createElement('button', { disabled: props.disabled }, props.children); }, })); diff --git a/Source/Dialogs/for_Dialog/when_rendered_with_is_busy.ts b/Source/Dialogs/for_Dialog/when_rendered_with_is_busy.ts index b25ec7c..9d8405e 100644 --- a/Source/Dialogs/for_Dialog/when_rendered_with_is_busy.ts +++ b/Source/Dialogs/for_Dialog/when_rendered_with_is_busy.ts @@ -6,17 +6,26 @@ import { renderToStaticMarkup } from 'react-dom/server'; import { vi } from 'vitest'; import { Dialog } from '../Dialog'; -vi.mock('primereact/dialog', () => ({ - Dialog: (props: { footer?: React.ReactNode; children?: React.ReactNode }) => - React.createElement('div', null, props.footer, props.children), -})); +vi.mock('primereact/dialog', () => { + // PrimeReact 11's Dialog is compositional; each part is a pass-through that + // renders its children so the footer buttons and content reach the markup. + const part = (props: { children?: React.ReactNode }) => React.createElement('div', null, props.children); + return { + Dialog: { + Root: part, Portal: part, Backdrop: part, Positioner: part, Popup: part, + Header: part, Title: part, Close: part, Content: part, Footer: part, + }, + }; +}); vi.mock('primereact/button', () => ({ - Button: (props: { icon?: string; label?: string; onClick?: () => void | Promise; disabled?: boolean; loading?: boolean }) => { - if (props.icon === 'pi pi-check' && props.onClick) { - props.onClick(); + // PrimeReact 11 Button renders children (the v10 label/icon props are gone); the + // confirm button carries autoFocus, which stands in for the click in this SSR render. + Button: (props: { autoFocus?: boolean; onClick?: () => void | Promise; disabled?: boolean; children?: React.ReactNode }) => { + if (props.autoFocus && props.onClick) { + void props.onClick(); } - return React.createElement('button', { disabled: props.disabled }, props.label); + return React.createElement('button', { disabled: props.disabled }, props.children); }, })); diff --git a/Source/Dropdown/Dropdown.tsx b/Source/Dropdown/Dropdown.tsx index cc32ff1..9129c8c 100644 --- a/Source/Dropdown/Dropdown.tsx +++ b/Source/Dropdown/Dropdown.tsx @@ -2,39 +2,142 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. import React from 'react'; -import { Dropdown as PrimeDropdown, DropdownProps as PrimeDropdownProps } from 'primereact/dropdown'; -import { useOverlayZIndex } from '../useOverlayZIndex'; +import { Select } from 'primereact/select'; +import type { SelectRootProps, SelectValueChangeEvent } from '@primereact/types/primitive/select'; /** - * Props for {@link Dropdown}. Identical to PrimeReact's `DropdownProps` — - * the wrapper does not add new props of its own. + * Change event emitted by {@link Dropdown}. Wrapper-owned so the public API does + * not leak a raw PrimeReact type; carries the newly selected `value` (a single + * option value, or an array when `multiple` is set) plus the originating event. */ -export type DropdownProps = PrimeDropdownProps; +export interface DropdownChangeEvent { + /** The newly selected value. An array of values when `multiple` is set. */ + value: T; + + /** The underlying React event that produced the change, when available. */ + originalEvent?: SelectValueChangeEvent['originalEvent']; +} + +/** + * Props for {@link Dropdown}. Wrapper-owned — the common single/multi select + * surface every Cratis form needs, without exposing PrimeReact's internal + * compositional Select parts. + */ +export interface DropdownProps { + /** The selected value. An array of values when `multiple` is set. */ + value?: T; + + /** Source array of option objects (or primitives). */ + options?: unknown[]; + + /** Property name on each option object used as the visible label. */ + optionLabel?: string; + + /** Property name on each option object used as the underlying value. */ + optionValue?: string; + + /** Placeholder shown in the trigger when nothing is selected. */ + placeholder?: string; + + /** When true, shows a filter input inside the options popup. */ + filter?: boolean; + + /** When true, the dropdown accepts multiple selections. */ + multiple?: boolean; + + /** When true, shows a clear control that resets the selection. */ + showClear?: boolean; + + /** Renders the trigger in an invalid (error) state. */ + invalid?: boolean; + + /** Disables the control. */ + disabled?: boolean; + + /** Extra CSS class name forwarded to the Select root. */ + className?: string; + + /** Fired when the selection changes. */ + onChange?: (event: DropdownChangeEvent) => void; + + /** Fired when focus leaves the control (rides the root's blur). */ + onBlur?: React.FocusEventHandler; + + /** PrimeReact pass-through configuration applied to the Select. */ + pt?: SelectRootProps['pt']; + + /** PrimeReact pass-through options applied to the Select. */ + ptOptions?: SelectRootProps['ptOptions']; + + /** When true, disables every base PrimeReact style on the Select. */ + unstyled?: boolean; +} /** - * Thin wrapper around PrimeReact's `Dropdown` that fixes two ergonomic - * issues for Cratis apps: + * Cratis single/multi select built on PrimeReact 11's compositional `Select`. * - * - Defaults `appendTo` to `document.body` so dropdown panels escape parent - * scroll containers (a common gotcha inside dialogs and data table rows). - * - Forces a high z-index on the dropdown panel via {@link useOverlayZIndex} - * so it appears above modal dialogs. + * PrimeReact 11 replaced the monolithic v10 `Dropdown` with a headless, + * compositional `Select` (Root → Trigger/Value → Portal → Positioner → Popup → + * List/Option). This wrapper assembles that composition once behind a small, + * familiar `value` / `options` / `optionLabel` / `optionValue` / `onChange` + * API so slices never touch the parts directly. `Select.List` auto-renders the + * `options`, so no manual option mapping is needed. * - * The component forwards refs and spreads all other props, so it accepts - * the full PrimeReact `Dropdown` API including `pt`, `ptOptions`, and - * `unstyled` for restyling. + * The options popup renders through `Select.Portal` and stacks correctly above + * modal dialogs via PrimeReact 11's overlay manager — the v10 `appendTo` / + * manual z-index workaround is no longer required. */ -export const Dropdown = React.forwardRef((props, ref) => { - // Force z-index on the dropdown panel to appear above dialogs - useOverlayZIndex('p-dropdown-panel'); - +export const Dropdown = ({ + value, + options, + optionLabel, + optionValue, + placeholder, + filter, + multiple, + showClear, + invalid, + disabled, + className, + onChange, + onBlur, + pt, + ptOptions, + unstyled, +}: DropdownProps) => { return ( - + // `onBlur` rides the wrapping span because React blur bubbles (focusout). + + + onChange?.({ value: event.value as T, originalEvent: event.originalEvent })} + pt={pt} + ptOptions={ptOptions} + unstyled={unstyled}> + + + {showClear && } + + + + + + {filter && } + + + + + + ); -}); +}; Dropdown.displayName = 'Dropdown'; diff --git a/Source/package.json b/Source/package.json index e2d8853..21ddc5f 100644 --- a/Source/package.json +++ b/Source/package.json @@ -11,12 +11,13 @@ "publishConfig": { "access": "public" }, + "type": "module", "files": [ "dist", "!dist/**/*.stories.*", "!dist/**/given.*" ], - "main": "dist/cjs/index.js", + "main": "dist/esm/index.js", "module": "dist/esm/index.js", "types": "dist/esm/index.d.ts", "sideEffects": [ @@ -26,92 +27,74 @@ "./package.json": "./package.json", ".": { "types": "./dist/esm/index.d.ts", - "require": "./dist/cjs/index.js", "import": "./dist/esm/index.js" }, "./CommandDialog": { "types": "./dist/esm/CommandDialog/index.d.ts", - "require": "./dist/cjs/CommandDialog/index.js", "import": "./dist/esm/CommandDialog/index.js" }, "./CommandStepper": { "types": "./dist/esm/CommandDialog/CommandStepper.d.ts", - "require": "./dist/cjs/CommandDialog/CommandStepper.js", "import": "./dist/esm/CommandDialog/CommandStepper.js" }, "./CommandForm": { "types": "./dist/esm/CommandForm/index.d.ts", - "require": "./dist/cjs/CommandForm/index.js", "import": "./dist/esm/CommandForm/index.js" }, "./CommandForm/fields": { "types": "./dist/esm/CommandForm/index.d.ts", - "require": "./dist/cjs/CommandForm/index.js", "import": "./dist/esm/CommandForm/index.js" }, "./Common": { "types": "./dist/esm/Common/index.d.ts", - "require": "./dist/cjs/Common/index.js", "import": "./dist/esm/Common/index.js" }, "./DataPage": { "types": "./dist/esm/DataPage/index.d.ts", - "require": "./dist/cjs/DataPage/index.js", "import": "./dist/esm/DataPage/index.js" }, "./DataTables": { "types": "./dist/esm/DataTables/index.d.ts", - "require": "./dist/cjs/DataTables/index.js", "import": "./dist/esm/DataTables/index.js" }, "./Dialogs": { "types": "./dist/esm/Dialogs/index.d.ts", - "require": "./dist/cjs/Dialogs/index.js", "import": "./dist/esm/Dialogs/index.js" }, "./Dropdown": { "types": "./dist/esm/Dropdown/index.d.ts", - "require": "./dist/cjs/Dropdown/index.js", "import": "./dist/esm/Dropdown/index.js" }, "./Filter": { "types": "./dist/esm/Filter/index.d.ts", - "require": "./dist/cjs/Filter/index.js", "import": "./dist/esm/Filter/index.js" }, "./ObjectContentEditor": { "types": "./dist/esm/ObjectContentEditor/index.d.ts", - "require": "./dist/cjs/ObjectContentEditor/index.js", "import": "./dist/esm/ObjectContentEditor/index.js" }, "./ObjectNavigationalBar": { "types": "./dist/esm/ObjectNavigationalBar/index.d.ts", - "require": "./dist/cjs/ObjectNavigationalBar/index.js", "import": "./dist/esm/ObjectNavigationalBar/index.js" }, "./PivotViewer": { "types": "./dist/esm/PivotViewer/index.d.ts", - "require": "./dist/cjs/PivotViewer/index.js", "import": "./dist/esm/PivotViewer/index.js" }, "./SchemaEditor": { "types": "./dist/esm/SchemaEditor/index.d.ts", - "require": "./dist/cjs/SchemaEditor/index.js", "import": "./dist/esm/SchemaEditor/index.js" }, "./TimeMachine": { "types": "./dist/esm/TimeMachine/index.d.ts", - "require": "./dist/cjs/TimeMachine/index.js", "import": "./dist/esm/TimeMachine/index.js" }, "./Toolbar": { "types": "./dist/esm/Toolbar/index.d.ts", - "require": "./dist/cjs/Toolbar/index.js", "import": "./dist/esm/Toolbar/index.js" }, "./types": { "types": "./dist/esm/types/index.d.ts", - "require": "./dist/cjs/types/index.js", "import": "./dist/esm/types/index.js" }, "./styles": "./dist/esm/tailwind-utilities.css", @@ -132,16 +115,19 @@ "build-storybook": "npx storybook build" }, "dependencies": { + "@primereact/core": "11.0.0", + "@primereact/headless": "11.0.0", "allotment": "1.20.5", "framer-motion": "12.42.2", "pixi.js": "^8.19.0", - "primeicons": "7.0.0", - "primereact": "10.9.8", + "primeicons": "8.0.0", + "primereact": "11.0.0", "react-icons": "5.7.0", "ts-deepmerge": "8.0.0" }, "devDependencies": { - "@cratis/arc.vite": "^20.49.2" + "@cratis/arc.vite": "^20.49.2", + "@primeuix/themes": "3.0.0" }, "peerDependencies": { "@cratis/arc": "^20.3.1", diff --git a/Source/rollup.config.mjs b/Source/rollup.config.mjs index 05d5336..99a6f1b 100644 --- a/Source/rollup.config.mjs +++ b/Source/rollup.config.mjs @@ -7,8 +7,7 @@ import pkg from './package.json' with { type: 'json' }; import path from "path"; -const cjsPath = path.dirname(pkg.main); const esmPath = path.dirname(pkg.module); const tsconfigPath = path.join(import.meta.dirname, "tsconfig.json"); -export default rollup(cjsPath, esmPath, tsconfigPath, pkg); +export default rollup(esmPath, tsconfigPath, pkg); diff --git a/Source/scripts/copy-css.sh b/Source/scripts/copy-css.sh index e2e5fdc..fa8ad9a 100755 --- a/Source/scripts/copy-css.sh +++ b/Source/scripts/copy-css.sh @@ -3,7 +3,8 @@ # Licensed under the MIT license. See LICENSE file in the project root for full license information. # Find all CSS files (excluding node_modules, dist, and .storybook) -# and copy them to both dist/esm and dist/cjs directories +# and copy them into the ESM output directory. PrimeReact 11 is ESM-only, so +# the package ships a single ESM build — there is no CJS output. find . -name '*.css' \ -not -path './node_modules/*' \ @@ -11,17 +12,15 @@ find . -name '*.css' \ -not -path './.storybook/*' \ -not -name 'tailwind.css' \ -not -name 'tailwind-utilities.css' | while read -r file; do - + # Remove the leading './' relative_path="${file#./}" - - # Create directory structure and copy file to both output directories + + # Create directory structure and copy file to the ESM output directory mkdir -p "dist/esm/$(dirname "$relative_path")" - mkdir -p "dist/cjs/$(dirname "$relative_path")" - + cp "$file" "dist/esm/$relative_path" - cp "$file" "dist/cjs/$relative_path" - + echo "Copied $relative_path" done diff --git a/rollup.config.mjs b/rollup.config.mjs index c91c002..4dbf899 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -10,20 +10,21 @@ import { dirname, join, resolve } from 'path'; /** * Rollup plugin that compiles the Tailwind entry CSS through PostCSS after the * bundle is written, producing a self-contained tailwind-utilities.css written - * into both the ESM and CJS output directories. + * into the ESM output directory. * * Because Rollup's external rule preserves CSS imports verbatim in the output - * JS, the `import './tailwind-utilities.css'` added to index.ts will be kept - * in dist/esm/index.js and dist/cjs/index.js. Vite (or any bundler) in the - * consuming app then finds the CSS file sitting next to the JS and injects it - * automatically — no manual import required in the consumer app. + * JS, any `import './tailwind-utilities.css'` is kept in dist/esm/index.js. + * Vite (or any bundler) in the consuming app then finds the CSS file sitting + * next to the JS and injects it automatically — no manual import required in + * the consumer app. Consumers reach it explicitly through the `./styles` + * package export. * * Without this step the utility classes (e.g. p-2, gap-1, w-10 …) only exist * in the package's source JSX and would never be generated by the consuming * app's own Tailwind build, because node_modules is typically excluded from * content scanning. */ -function compileTailwind(sourceDir, cjsPath, esmPath) { +function compileTailwind(sourceDir, esmPath) { let hasRun = false; return { name: 'compile-tailwind', @@ -39,38 +40,27 @@ function compileTailwind(sourceDir, cjsPath, esmPath) { const css = readFileSync(inputFile, 'utf8'); - for (const outputDir of [cjsPath, esmPath]) { - const outputFile = resolve(outputDir, 'tailwind-utilities.css'); - const result = await postcss([tailwindcss({ base: sourceDir }), autoprefixer]).process(css, { - from: inputFile, - to: outputFile, - }); - mkdirSync(dirname(outputFile), { recursive: true }); - writeFileSync(outputFile, result.css); - } - console.log('✓ Compiled Tailwind utilities → dist/{cjs,esm}/tailwind-utilities.css'); + const outputFile = resolve(esmPath, 'tailwind-utilities.css'); + const result = await postcss([tailwindcss({ base: sourceDir }), autoprefixer]).process(css, { + from: inputFile, + to: outputFile, + }); + mkdirSync(dirname(outputFile), { recursive: true }); + writeFileSync(outputFile, result.css); + console.log('✓ Compiled Tailwind utilities → dist/esm/tailwind-utilities.css'); }, }; } /** - * Rollup plugin to generate package.json files in output directories - * This ensures proper module resolution for both CJS and ESM formats + * Rollup plugin to generate the package.json in the ESM output directory, + * marking it as an ES module. PrimeReact 11 is ESM-only, so the package ships + * a single ESM build — there is no CJS output. */ -function generatePackageJson(cjsPath, esmPath) { +function generatePackageJson(esmPath) { return { name: 'generate-package-json', buildEnd() { - // Create CJS package.json - const cjsDir = cjsPath; - mkdirSync(cjsDir, { recursive: true }); - writeFileSync( - join(cjsDir, 'package.json'), - JSON.stringify({ type: 'commonjs' }, null, 2), - 'utf-8' - ); - - // Create ESM package.json const esmDir = esmPath; mkdirSync(esmDir, { recursive: true }); writeFileSync( @@ -79,25 +69,17 @@ function generatePackageJson(cjsPath, esmPath) { 'utf-8' ); - console.log('✓ Generated package.json files for CJS and ESM outputs'); + console.log('✓ Generated package.json for ESM output'); } }; } -export function rollup(cjsPath, esmPath, tsconfigPath, pkg) { +export function rollup(esmPath, tsconfigPath, pkg) { const sourceDir = dirname(tsconfigPath); return { input: 'index.ts', output: [ - { - dir: cjsPath, - format: "cjs", - exports: "named", - sourcemap: true, - preserveModules: true, - preserveModulesRoot: "." - }, { dir: esmPath, format: "es", @@ -113,6 +95,8 @@ export function rollup(cjsPath, esmPath, tsconfigPath, pkg) { /^@cratis\/components/, /^@cratis\/arc/, /^primereact\//, + /^@primereact\//, + /^@primeuix\//, /^primeicons/, /^react-icons\//, /\.css$/, From 1375c84bf0f365c1076961689883387f756cf141 Mon Sep 17 00:00:00 2001 From: woksin Date: Thu, 16 Jul 2026 11:23:08 +0200 Subject: [PATCH 02/42] Rebuild Stepper stack on PrimeReact 11 compositional Stepper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 5 of the PrimeReact 11 migration. - Rebuild CommandStepper on the v11 compositional Stepper (Stepper.Root/List/Step/Header/Number/Title/Separator/Panels/Panel), driven by a controlled `value` + `onValueChange`. - Add a Cratis-owned StepperPanel marker to replace the removed `primereact/stepperpanel`; the stepper reads each panel's header and children to build the step list and content panels. - Replace the leaked `StepperCustomizationProps = Pick` with a wrapper-owned type; drop the v10-only orientation/headerPosition/ start/end slots that have no v11 equivalent. - Re-express the per-step red/green error indicator directly on each Stepper.Number's style instead of the v10 pt.stepperpanel.number hook. - Switch StepperCommandDialog off raw PrimeDialog onto the Cratis Dialog wrapper, and convert every Button to v11 children + variant. - Add an additive `dismissable` prop to the Cratis Dialog so a custom-footer wizard keeps its header-close affordance. - Update the stepper stories and the four StepperCommandDialog specs to the v11 component shapes (Cratis Dialog + Stepper parts + children Buttons). Co-Authored-By: Claude Opus 4.8 (1M context) --- Source/CommandDialog/CommandStepper.css | 14 +- .../CommandDialog/CommandStepper.stories.tsx | 2 +- Source/CommandDialog/CommandStepper.tsx | 225 +++++++++--------- .../StepperCommandDialog.stories.tsx | 2 +- Source/CommandDialog/StepperCommandDialog.tsx | 189 +++++++-------- Source/CommandDialog/StepperPanel.tsx | 40 ++++ .../when_form_is_invalid.ts | 57 ++--- .../when_not_executing.ts | 45 ++-- .../when_single_step.ts | 36 +-- .../when_step_has_field_errors.ts | 93 ++++---- Source/CommandDialog/index.ts | 1 + Source/Dialogs/Dialog.tsx | 19 +- 12 files changed, 374 insertions(+), 349 deletions(-) create mode 100644 Source/CommandDialog/StepperPanel.tsx diff --git a/Source/CommandDialog/CommandStepper.css b/Source/CommandDialog/CommandStepper.css index b42506c..b3e2f12 100644 --- a/Source/CommandDialog/CommandStepper.css +++ b/Source/CommandDialog/CommandStepper.css @@ -1,10 +1,6 @@ /* Copyright (c) Cratis. All rights reserved. */ /* Licensed under the MIT license. See LICENSE file in the project root for full license information. */ -.p-stepper [data-pc-section="header"][data-p-active="false"] { - opacity: 0.5; -} - /* Make stepper and its panels fill available width */ .cratis-command-stepper { display: flex; @@ -13,12 +9,18 @@ width: 100%; } -.cratis-command-stepper .p-stepper { +/* PrimeReact 11 stepper parts carry data-scope/data-part attributes rather + than the v10 .p-stepper* class names, so key structural rules off those. */ +.cratis-command-stepper [data-scope="stepper"][data-part="root"] { width: 100%; } -.cratis-command-stepper .p-stepperpanel-content { +.cratis-command-stepper [data-scope="stepper"][data-part="panel"] { width: 100%; box-sizing: border-box; } +/* Dim the header of steps that are not currently active */ +.cratis-command-stepper [data-scope="stepper"][data-part="step"]:not([data-active]) [data-part="header"] { + opacity: 0.5; +} diff --git a/Source/CommandDialog/CommandStepper.stories.tsx b/Source/CommandDialog/CommandStepper.stories.tsx index f42729b..a3ca7f2 100644 --- a/Source/CommandDialog/CommandStepper.stories.tsx +++ b/Source/CommandDialog/CommandStepper.stories.tsx @@ -3,7 +3,7 @@ import React, { useState } from 'react'; import type { Meta, StoryObj } from '@storybook/react'; -import { StepperPanel } from 'primereact/stepperpanel'; +import { StepperPanel } from './StepperPanel'; import { CommandStepper } from './CommandStepper'; import { Command, CommandResult, CommandValidator } from '@cratis/arc/commands'; import { PropertyDescriptor } from '@cratis/arc/reflection'; diff --git a/Source/CommandDialog/CommandStepper.tsx b/Source/CommandDialog/CommandStepper.tsx index 92d19b0..4ea2ba1 100644 --- a/Source/CommandDialog/CommandStepper.tsx +++ b/Source/CommandDialog/CommandStepper.tsx @@ -1,8 +1,9 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. -import React, { useEffect, useMemo, useState } from 'react'; -import { Stepper as PrimeStepper, type StepperProps } from 'primereact/stepper'; +import React, { useEffect, useMemo, useState, type CSSProperties } from 'react'; +import { Stepper } from 'primereact/stepper'; +import type { StepperRootProps } from '@primereact/types/primitive/stepper'; import { Button } from 'primereact/button'; import { ICommandResult } from '@cratis/arc/commands'; import { @@ -13,15 +14,44 @@ import { type CommandFormProps } from '@cratis/arc.react/commands'; import { applyBeforeExecute, type BeforeExecuteCallback } from './applyBeforeExecute'; +import type { StepperPanelProps } from './StepperPanel'; import './CommandStepper.css'; /** - * Stepper-specific customization props forwarded directly to PrimeReact Stepper. - * `activeStep` and `children` are managed by the component. + * Event passed to {@link StepperCustomizationProps.onChangeStep} when the user + * navigates to a different step. */ -export type StepperCustomizationProps = Pick; +export interface StepperChangeEvent { + /** Zero-based index of the step being navigated to. */ + index: number; +} + +/** + * Stepper-specific customization surface exposed by {@link CommandStepper} and + * {@link StepperCommandDialog}. This is a Cratis-owned type — it no longer + * leaks PrimeReact's `StepperProps` — so PrimeReact 11's compositional Stepper + * can be rebuilt underneath without changing the public API. + * + * The PrimeReact 10 slots `orientation`, `headerPosition`, `start`, and `end` + * have no PrimeReact 11 equivalent and were removed. + */ +export interface StepperCustomizationProps { + /** + * Whether the wizard is linear. In linear mode the step headers are not + * directly clickable — the user advances through the Previous / Next + * buttons. Set to `false` to let the user jump between steps by clicking + * their headers. Defaults to `true`. + */ + linear?: boolean; + /** Invoked when the active step changes (via navigation or a header click). */ + onChangeStep?: (event: StepperChangeEvent) => void; + /** PrimeReact pass-through configuration for the underlying Stepper parts. */ + pt?: StepperRootProps['pt']; + /** PrimeReact pass-through options controlling merge vs. replace for {@link pt}. */ + ptOptions?: StepperRootProps['ptOptions']; + /** When true, disables every base PrimeReact style on the Stepper. */ + unstyled?: boolean; +} export interface CommandStepperContentProps extends StepperCustomizationProps { /** The active step index. */ @@ -127,6 +157,9 @@ const processChildren = (nodes: React.ReactNode): React.ReactNode => { }); }; +/** The value used to identify a step in PrimeReact 11's Stepper — its index as a string. */ +const stepValue = (index: number): string => String(index); + export const CommandStepperContent = ({ activeStep, visitedSteps, @@ -144,27 +177,23 @@ export const CommandStepperContent = ({ isSubmitting = false, isSubmitDisabled = false, onSubmit, - orientation = 'horizontal', - headerPosition, linear = true, onChangeStep, - start, - end, pt, ptOptions, unstyled, }: CommandStepperContentProps) => { - const stepCount = React.Children.count(children); + const panels = useMemo( + () => React.Children.toArray(children).filter(React.isValidElement) as React.ReactElement[], + [children] + ); + const stepCount = panels.length; const isLastStep = activeStep >= stepCount - 1; const isFirstStep = activeStep <= 0; const stepFieldNames = useMemo( - () => React.Children.toArray(children).map((step) => { - if (!React.isValidElement(step)) return [] as string[]; - const stepProps = step.props as Record; - return extractFieldNamesFromNode(stepProps.children as React.ReactNode); - }), - [children] + () => panels.map((panel) => extractFieldNamesFromNode(panel.props.children)), + [panels] ); const stepErrors = useMemo( @@ -179,59 +208,34 @@ export const CommandStepperContent = ({ const isCurrentStepInvalid = stepErrors[activeStep] ?? false; const hasAnyStepErrors = stepErrors.some(hasError => hasError); - const stepperPt = useMemo(() => { - type StepContext = { context: { index: number } }; - type NumberPtFn = (opts: StepContext) => Record; - - const userPt = pt as Record | undefined; - const userStepperPanelPt = userPt?.stepperpanel as Record | undefined; - const userNumberPt = userStepperPanelPt?.number; - - return { - ...userPt, - stepperpanel: { - ...userStepperPanelPt, - number: (opts: StepContext) => { - const existing: Record = - typeof userNumberPt === 'function' - ? (userNumberPt as NumberPtFn)(opts) - : (userNumberPt as Record | undefined) ?? {}; - const idx = opts.context.index; - const hasError = stepErrors[idx] ?? false; - const isVisited = visitedSteps.has(idx); - - // Use straightforward color names here so unit tests that - // inspect the computed `backgroundColor` can assert reliably. - const bgColor = hasError - ? 'red' - : isVisited - ? 'green' - : null; - - if (!bgColor) return existing; - const existingStyle = existing.style as Record | undefined; - return { - ...existing, - style: { ...existingStyle, backgroundColor: bgColor, color: 'var(--cratis-primary-color-text)' } - }; - } - } - }; - }, [pt, stepErrors, visitedSteps]); - - const handleChangeStep: StepperProps['onChangeStep'] = event => { - onChangeStep?.(event); - const index = (event as { index?: number }).index; - if (typeof index === 'number') { - if (index > activeStep && isCurrentStepInvalid) { - return; - } + // The per-step number indicator paints red when a visited step still has a + // field error and green once a step has been visited without errors — a + // traffic-light status marker that is intentionally theme-independent, so + // the literal color names are appropriate here (and let specs assert on the + // computed background reliably). + const numberStyle = (index: number): CSSProperties | undefined => { + const hasError = stepErrors[index] ?? false; + const isVisited = visitedSteps.has(index); + const backgroundColor = hasError ? 'red' : isVisited ? 'green' : undefined; + if (!backgroundColor) return undefined; + return { backgroundColor, color: 'var(--cratis-primary-color-text)' }; + }; - if (index > activeStep) { - onVisitedStepsChange?.(new Set(visitedSteps).add(activeStep)); - } - onActiveStepChange?.(index); + const handleValueChange: StepperRootProps['onValueChange'] = event => { + const raw = event.value; + const index = typeof raw === 'number' ? raw : parseInt(String(raw ?? ''), 10); + if (Number.isNaN(index)) return; + + onChangeStep?.({ index }); + + if (index > activeStep && isCurrentStepInvalid) { + return; + } + + if (index > activeStep) { + onVisitedStepsChange?.(new Set(visitedSteps).add(activeStep)); } + onActiveStepChange?.(index); }; const handlePrevious = () => { @@ -249,54 +253,64 @@ export const CommandStepperContent = ({ return (
- - {processChildren(children)} - + unstyled={unstyled}> + + {panels.map((panel, index) => ( + + + {index + 1} + {panel.props.header} + + {index < stepCount - 1 && } + + ))} + + + {panels.map((panel, index) => ( + + {processChildren(panel.props.children)} + + ))} + + {showNavigation && (
{!isFirstStep && ( )}
{!isLastStep && ( )} {isLastStep && showSubmit && ( )}
)} @@ -325,12 +339,8 @@ const CommandStepperWrapper = ({ previousLabel, okLabel, isBusy, - orientation, - headerPosition, linear, onChangeStep, - start, - end, pt, ptOptions, unstyled, @@ -390,12 +400,8 @@ const CommandStepperWrapper = ({ isSubmitting={isSubmitting} isSubmitDisabled={!isCommandFormValid} onSubmit={handleSubmit} - orientation={orientation} - headerPosition={headerPosition} linear={linear} onChangeStep={onChangeStep} - start={start} - end={end} pt={pt} ptOptions={ptOptions} unstyled={unstyled} @@ -444,8 +450,7 @@ const CommandStepperWrapper = ({ * Stepper directly. * * ```tsx - * import { CommandStepper } from '@cratis/components/CommandStepper'; - * import { StepperPanel } from 'primereact/stepperpanel'; + * import { CommandStepper, StepperPanel } from '@cratis/components/CommandDialog'; * import { RegisterAuthor } from './RegisterAuthor'; // proxy from C# * * export const RegisterAuthorPage = () => ( @@ -477,12 +482,8 @@ export const CommandStepper = = { diff --git a/Source/CommandDialog/StepperCommandDialog.tsx b/Source/CommandDialog/StepperCommandDialog.tsx index 3ac532e..2d8bda4 100644 --- a/Source/CommandDialog/StepperCommandDialog.tsx +++ b/Source/CommandDialog/StepperCommandDialog.tsx @@ -1,25 +1,24 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. +import React, { useState, type CSSProperties } from 'react'; import { ICommandResult } from '@cratis/arc/commands'; import { DialogResult, useDialogContext } from '@cratis/arc.react/dialogs'; -import { Dialog as PrimeDialog, type DialogProps as PrimeDialogProps } from 'primereact/dialog'; import { Button } from 'primereact/button'; -import React, { useState } from 'react'; import { CommandForm, useCommandFormContext, useCommandInstance, type CommandFormProps } from '@cratis/arc.react/commands'; -import type { CloseDialog, ConfirmCallback, CancelCallback } from '../Dialogs/Dialog'; +import { Dialog, type DialogProps, type CloseDialog, type ConfirmCallback, type CancelCallback } from '../Dialogs/Dialog'; import { CommandStepperContent, type StepperCustomizationProps } from './CommandStepper'; import { applyBeforeExecute, type BeforeExecuteCallback } from './applyBeforeExecute'; /** * Props for {@link StepperCommandDialog}. Combines the command-form props, - * the stepper customization props (`orientation`, `headerPosition`, `linear`, - * `pt`, …), and dialog-specific props for the outer modal. + * the stepper customization props (`linear`, `pt`, …), and dialog-specific + * props for the outer modal. * * The Stepper customization props (`pt`/`ptOptions`/`unstyled`) target the * inner Stepper. To customize the outer Dialog use `dialogPt`, `dialogPtOptions`, @@ -51,9 +50,9 @@ export interface StepperCommandDialogProps = { + title: string; + visible?: boolean; + width?: string; + style?: CSSProperties; + contentStyle?: CSSProperties; + resizable?: boolean; + isValid?: boolean; + onClose?: CloseDialog; + onConfirm?: ConfirmCallback; + onCancel?: CancelCallback; + onSuccess?: CommandFormProps['onSuccess']; + onValidationFailure?: CommandFormProps['onValidationFailure']; + onFailed?: CommandFormProps['onFailed']; + onBeforeExecute?: BeforeExecuteCallback; + okLabel?: string; + nextLabel?: string; + previousLabel?: string; + dialogClassName?: string; + dialogPt?: DialogProps['pt']; + dialogPtOptions?: DialogProps['ptOptions']; + dialogUnstyled?: boolean; + children?: React.ReactNode; +} & StepperCustomizationProps; + const StepperCommandDialogWrapper = ({ title, visible = true, @@ -103,12 +127,8 @@ const StepperCommandDialogWrapper = ['onSuccess']; - onValidationFailure?: CommandFormProps['onValidationFailure']; - onFailed?: CommandFormProps['onFailed']; - onBeforeExecute?: BeforeExecuteCallback; - okLabel?: string; - nextLabel?: string; - previousLabel?: string; - dialogClassName?: string; - dialogPt?: PrimeDialogProps['pt']; - dialogPtOptions?: PrimeDialogProps['ptOptions']; - dialogUnstyled?: boolean; - children?: React.ReactNode; -} & StepperCustomizationProps) => { +}: StepperCommandDialogWrapperProps) => { const { setCommandValues, setCommandResult, isValid: isCommandFormValid, getFieldError } = useCommandFormContext(); const commandInstance = useCommandInstance(); const [isBusy, setIsBusy] = useState(false); @@ -159,35 +156,29 @@ const StepperCommandDialogWrapper = { - let shouldCloseThroughContext = true; - - if (result === DialogResult.Ok || result === DialogResult.Yes) { - if (onConfirm) { - const closeResult = await onConfirm(); - shouldCloseThroughContext = closeResult === true; - } else if (onClose) { - const closeResult = await onClose(result); - shouldCloseThroughContext = closeResult !== false; - } - } else { - if (onCancel) { - const closeResult = await onCancel(); - shouldCloseThroughContext = closeResult === true; - } else if (onClose) { - const closeResult = await onClose(result); - shouldCloseThroughContext = closeResult !== false; - } + // Cancel/dismiss closing is owned by the outer Cratis Dialog (it runs the + // user's onCancel/onClose and closes through the dialog host). This wrapper + // only owns the success-close path — the custom Submit button cannot go + // through the Dialog's own confirm button, so it closes through the context + // itself after running the confirm gate. + const closeAfterSuccess = async () => { + let shouldClose = true; + if (onConfirm) { + const closeResult = await onConfirm(); + shouldClose = closeResult === true; + } else if (onClose) { + const closeResult = await onClose(DialogResult.Ok); + shouldClose = closeResult !== false; } - if (shouldCloseThroughContext) { - contextCloseDialog?.(result); + if (shouldClose) { + contextCloseDialog?.(DialogResult.Ok); } }; @@ -217,64 +208,59 @@ const StepperCommandDialogWrapper = - {title} -
- ); - const footer = ( -
+
{!isFirstStep && ( )} -
+
{!isLastStep && ( )} {isLastStep && isDialogValid && ( )}
); return ( - handleClose(DialogResult.Cancelled)} + {children} - + ); }; @@ -351,8 +333,7 @@ const StepperCommandDialogWrapper = { @@ -409,12 +390,8 @@ const StepperCommandDialogComponent = `; the enclosing stepper reads each panel's + * `header` and `children` to build the underlying PrimeReact 11 Stepper + * structure (a `Stepper.Step` in the header list plus a matching + * `Stepper.Panel` in the content area). + * + * This is the Cratis-owned replacement for PrimeReact 10's + * `primereact/stepperpanel`, which no longer exists in PrimeReact 11. It is a + * pure marker: it is never rendered on its own — the parent stepper consumes + * its props — so it renders nothing when mounted directly. + * + * ```tsx + * import { StepperPanel } from '@cratis/components/CommandDialog'; + * + * + * c.name} title="Name" /> + * + * ``` + * + * @param props - {@link StepperPanelProps}. + */ +export const StepperPanel = (_props: StepperPanelProps): React.ReactElement | null => null; +StepperPanel.displayName = 'StepperPanel'; diff --git a/Source/CommandDialog/for_StepperCommandDialog/when_form_is_invalid.ts b/Source/CommandDialog/for_StepperCommandDialog/when_form_is_invalid.ts index c069738..3fca06e 100644 --- a/Source/CommandDialog/for_StepperCommandDialog/when_form_is_invalid.ts +++ b/Source/CommandDialog/for_StepperCommandDialog/when_form_is_invalid.ts @@ -10,44 +10,35 @@ const { commandFormValidity, executeCommand } = vi.hoisted(() => ({ executeCommand: vi.fn(async () => ({ isSuccess: true, isValid: true, validationResults: [] })) })); -vi.mock('primereact/dialog', () => ({ - Dialog: (props: { footer?: React.ReactNode; children?: React.ReactNode }) => - React.createElement('div', null, props.footer, props.children), +vi.mock('../../Dialogs/Dialog', () => ({ + Dialog: (props: { buttons?: React.ReactNode; children?: React.ReactNode }) => + React.createElement('div', null, props.buttons, props.children), })); -vi.mock('primereact/stepper', () => ({ - Stepper: (props: { children?: React.ReactNode; pt?: Record; activeStep?: number }) => { - type StepCtx = { context: { index: number } }; - type NumberPtFn = (opts: StepCtx) => { style?: { backgroundColor?: string } }; - const ptStepperpanel = (props.pt as Record | undefined)?.stepperpanel as Record | undefined; - const numberPtFn = ptStepperpanel?.number as NumberPtFn | undefined; - const children = React.Children.map(props.children, (child, index) => { - if (!React.isValidElement(child)) return child; - const result = typeof numberPtFn === 'function' ? numberPtFn({ context: { index } }) : {}; - const bg = result?.style?.backgroundColor ?? ''; - return React.cloneElement(child as React.ReactElement>, { 'data-number-bg': bg }); - }); - return React.createElement('div', { 'data-testid': 'stepper', 'data-active-step': props.activeStep }, children); - }, -})); - -vi.mock('primereact/stepperpanel', () => { - const MockStepperPanel = (props: { header?: string; children?: React.ReactNode; 'data-number-bg'?: string }) => - React.createElement('div', { - 'data-testid': 'stepper-panel', - 'data-header': props.header, - 'data-number-bg': props['data-number-bg'] ?? '', - }, props.children); - MockStepperPanel.displayName = 'StepperPanel'; - return { StepperPanel: MockStepperPanel }; +vi.mock('primereact/stepper', () => { + const part = (name: string) => { + const Component = (props: { children?: React.ReactNode; style?: React.CSSProperties }) => + React.createElement('div', { 'data-part': name, style: props.style }, props.children); + Component.displayName = name; + return Component; + }; + return { + Stepper: { + Root: part('root'), List: part('list'), Step: part('step'), + Header: part('header'), Number: part('number'), Title: part('title'), + Separator: part('separator'), Panels: part('panels'), Panel: part('panel'), + }, + }; }); +// The submit button is the only one carrying autoFocus — auto-activate it (when +// enabled) so the spec can assert whether the command executes. vi.mock('primereact/button', () => ({ - Button: (props: { icon?: string; label?: string; onClick?: () => Promise | void; disabled?: boolean; loading?: boolean }) => { - if (props.icon === 'pi pi-check' && props.onClick && props.disabled !== true) { + Button: (props: { children?: React.ReactNode; onClick?: () => Promise | void; disabled?: boolean; autoFocus?: boolean }) => { + if (props.autoFocus && props.onClick && props.disabled !== true) { void props.onClick(); } - return React.createElement('button', { disabled: props.disabled, 'data-loading': props.loading }, props.label); + return React.createElement('button', { disabled: props.disabled }, props.children); }, })); @@ -77,13 +68,13 @@ class TestCommand { } let StepperCommandDialog: typeof import('../StepperCommandDialog').StepperCommandDialog; -let StepperPanel: typeof import('primereact/stepperpanel').StepperPanel; +let StepperPanel: typeof import('../StepperPanel').StepperPanel; beforeEach(async () => { executeCommand.mockClear(); vi.resetModules(); StepperCommandDialog = (await import('../StepperCommandDialog')).StepperCommandDialog; - StepperPanel = (await import('primereact/stepperpanel')).StepperPanel; + StepperPanel = (await import('../StepperPanel')).StepperPanel; }); afterEach(() => { diff --git a/Source/CommandDialog/for_StepperCommandDialog/when_not_executing.ts b/Source/CommandDialog/for_StepperCommandDialog/when_not_executing.ts index 09453e3..07d656c 100644 --- a/Source/CommandDialog/for_StepperCommandDialog/when_not_executing.ts +++ b/Source/CommandDialog/for_StepperCommandDialog/when_not_executing.ts @@ -5,26 +5,37 @@ import React from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; import { vi } from 'vitest'; import { StepperCommandDialog } from '../StepperCommandDialog'; -import { StepperPanel } from 'primereact/stepperpanel'; +import { StepperPanel } from '../StepperPanel'; -vi.mock('primereact/dialog', () => ({ - Dialog: (props: { footer?: React.ReactNode; children?: React.ReactNode }) => - React.createElement('div', { 'data-testid': 'dialog' }, props.footer, props.children), +// StepperCommandDialog now composes the Cratis Dialog wrapper (not primereact/dialog +// directly) — render its custom footer (`buttons`) and body. +vi.mock('../../Dialogs/Dialog', () => ({ + Dialog: (props: { buttons?: React.ReactNode; children?: React.ReactNode }) => + React.createElement('div', { 'data-testid': 'dialog' }, props.buttons, props.children), })); -vi.mock('primereact/stepper', () => ({ - Stepper: (props: { children?: React.ReactNode }) => - React.createElement('div', { 'data-testid': 'stepper' }, props.children), -})); - -vi.mock('primereact/stepperpanel', () => ({ - StepperPanel: (props: { header?: string; children?: React.ReactNode }) => - React.createElement('div', { 'data-testid': 'stepper-panel', 'data-header': props.header }, props.children), -})); +// PrimeReact 11's Stepper is a set of compositional parts — each just renders its +// children so the footer/navigation behavior can be asserted. +vi.mock('primereact/stepper', () => { + const part = (name: string) => { + const Component = (props: { children?: React.ReactNode; style?: React.CSSProperties }) => + React.createElement('div', { 'data-part': name, style: props.style }, props.children); + Component.displayName = name; + return Component; + }; + return { + Stepper: { + Root: part('root'), List: part('list'), Step: part('step'), + Header: part('header'), Number: part('number'), Title: part('title'), + Separator: part('separator'), Panels: part('panels'), Panel: part('panel'), + }, + }; +}); +// PrimeReact 11's Button renders its content as children (no label/loading props). vi.mock('primereact/button', () => ({ - Button: (props: { label?: string; disabled?: boolean; loading?: boolean; icon?: string }) => - React.createElement('button', { disabled: props.disabled, 'data-loading': props.loading }, props.label), + Button: (props: { children?: React.ReactNode; disabled?: boolean }) => + React.createElement('button', { disabled: props.disabled }, props.children), })); vi.mock('@cratis/arc.react/dialogs', () => ({ @@ -69,8 +80,8 @@ describe('when StepperCommandDialog is in its initial state', () => { html = renderToStaticMarkup(element); }); - it('should_not_have_buttons_disabled_due_to_busy', () => { - html.should.not.include('data-loading="true"'); + it('should_not_have_buttons_in_a_busy_state', () => { + html.should.not.include('pi-spinner'); }); it('should_not_show_previous_button_on_first_step', () => { diff --git a/Source/CommandDialog/for_StepperCommandDialog/when_single_step.ts b/Source/CommandDialog/for_StepperCommandDialog/when_single_step.ts index 4e33e70..c72789d 100644 --- a/Source/CommandDialog/for_StepperCommandDialog/when_single_step.ts +++ b/Source/CommandDialog/for_StepperCommandDialog/when_single_step.ts @@ -5,26 +5,32 @@ import React from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; import { vi } from 'vitest'; import { StepperCommandDialog } from '../StepperCommandDialog'; -import { StepperPanel } from 'primereact/stepperpanel'; +import { StepperPanel } from '../StepperPanel'; -vi.mock('primereact/dialog', () => ({ - Dialog: (props: { footer?: React.ReactNode; children?: React.ReactNode }) => - React.createElement('div', { 'data-testid': 'dialog' }, props.footer, props.children), +vi.mock('../../Dialogs/Dialog', () => ({ + Dialog: (props: { buttons?: React.ReactNode; children?: React.ReactNode }) => + React.createElement('div', { 'data-testid': 'dialog' }, props.buttons, props.children), })); -vi.mock('primereact/stepper', () => ({ - Stepper: (props: { children?: React.ReactNode; activeStep?: number }) => - React.createElement('div', { 'data-testid': 'stepper', 'data-active-step': props.activeStep }, props.children), -})); - -vi.mock('primereact/stepperpanel', () => ({ - StepperPanel: (props: { header?: string; children?: React.ReactNode }) => - React.createElement('div', { 'data-testid': 'stepper-panel', 'data-header': props.header }, props.children), -})); +vi.mock('primereact/stepper', () => { + const part = (name: string) => { + const Component = (props: { children?: React.ReactNode; style?: React.CSSProperties }) => + React.createElement('div', { 'data-part': name, style: props.style }, props.children); + Component.displayName = name; + return Component; + }; + return { + Stepper: { + Root: part('root'), List: part('list'), Step: part('step'), + Header: part('header'), Number: part('number'), Title: part('title'), + Separator: part('separator'), Panels: part('panels'), Panel: part('panel'), + }, + }; +}); vi.mock('primereact/button', () => ({ - Button: (props: { label?: string; disabled?: boolean; loading?: boolean }) => - React.createElement('button', { disabled: props.disabled, 'data-loading': props.loading }, props.label), + Button: (props: { children?: React.ReactNode; disabled?: boolean }) => + React.createElement('button', { disabled: props.disabled }, props.children), })); vi.mock('@cratis/arc.react/dialogs', () => ({ diff --git a/Source/CommandDialog/for_StepperCommandDialog/when_step_has_field_errors.ts b/Source/CommandDialog/for_StepperCommandDialog/when_step_has_field_errors.ts index 1bf0e1a..557d1e0 100644 --- a/Source/CommandDialog/for_StepperCommandDialog/when_step_has_field_errors.ts +++ b/Source/CommandDialog/for_StepperCommandDialog/when_step_has_field_errors.ts @@ -4,52 +4,34 @@ import React from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; import { vi } from 'vitest'; -import { StepperCommandDialog } from '../StepperCommandDialog'; -import { StepperPanel } from 'primereact/stepperpanel'; -vi.mock('primereact/dialog', () => ({ - Dialog: (props: { footer?: React.ReactNode; children?: React.ReactNode }) => - React.createElement('div', { 'data-testid': 'dialog' }, props.footer, props.children), +vi.mock('../../Dialogs/Dialog', () => ({ + Dialog: (props: { buttons?: React.ReactNode; children?: React.ReactNode }) => + React.createElement('div', { 'data-testid': 'dialog' }, props.buttons, props.children), })); -// Simulate PrimeReact's Stepper: invoke pt.stepperpanel.number for each child and -// attach the resulting backgroundColor as data-number-bg so specs can assert on it. -vi.mock('primereact/stepper', () => ({ - Stepper: (props: { children?: React.ReactNode; pt?: Record; activeStep?: number }) => { - type StepCtx = { context: { index: number } }; - type NumberPtFn = (opts: StepCtx) => { style?: { backgroundColor?: string } }; - const ptStepperpanel = (props.pt as Record | undefined)?.stepperpanel as Record | undefined; - const numberPtFn = ptStepperpanel?.number as NumberPtFn | undefined; - const children = React.Children.map(props.children, (child, index) => { - if (!React.isValidElement(child)) return child; - const result = typeof numberPtFn === 'function' ? numberPtFn({ context: { index } }) : {}; - const bg = result?.style?.backgroundColor ?? ''; - return React.cloneElement(child as React.ReactElement>, { 'data-number-bg': bg }); - }); - return React.createElement('div', { 'data-testid': 'stepper' }, children); - }, -})); - -// Set displayName so the indicator code path in processChildren is triggered. -// Forward data-number-bg (injected by the Stepper mock above) so specs can assert on it. -vi.mock('primereact/stepperpanel', () => { - const MockStepperPanel = (props: { - header?: string; - children?: React.ReactNode; - 'data-number-bg'?: string; - }) => - React.createElement('div', { - 'data-testid': 'stepper-panel', - 'data-header': props.header, - 'data-number-bg': props['data-number-bg'] ?? '', - }, props.children); - MockStepperPanel.displayName = 'StepperPanel'; - return { StepperPanel: MockStepperPanel }; +// PrimeReact 11's Stepper is compositional: each part renders its children, and +// the Number part forwards its inline `style` so specs can assert the per-step +// red/green indicator the wrapper applies directly to each step's number. +vi.mock('primereact/stepper', () => { + const part = (name: string) => { + const Component = (props: { children?: React.ReactNode; style?: React.CSSProperties }) => + React.createElement('div', { 'data-part': name, style: props.style }, props.children); + Component.displayName = name; + return Component; + }; + return { + Stepper: { + Root: part('root'), List: part('list'), Step: part('step'), + Header: part('header'), Number: part('number'), Title: part('title'), + Separator: part('separator'), Panels: part('panels'), Panel: part('panel'), + }, + }; }); vi.mock('primereact/button', () => ({ - Button: (props: { label?: string; disabled?: boolean; loading?: boolean }) => - React.createElement('button', { disabled: props.disabled, 'data-loading': props.loading }, props.label), + Button: (props: { children?: React.ReactNode; disabled?: boolean }) => + React.createElement('button', { disabled: props.disabled }, props.children), })); vi.mock('@cratis/arc.react/dialogs', () => ({ @@ -87,10 +69,21 @@ class TestCommand { description: string = ''; } +// The project runs specs with `isolate: false`, so module state and mocks are +// shared across files by execution order. Re-evaluate the component under this +// file's own mocks so the getFieldError stub (which drives the error color) is +// always the one in effect, regardless of which spec file ran first. +let StepperCommandDialog: typeof import('../StepperCommandDialog').StepperCommandDialog; +let StepperPanel: typeof import('../StepperPanel').StepperPanel; + describe('when a step contains a field with a validation error', () => { let html: string; - beforeEach(() => { + beforeEach(async () => { + vi.resetModules(); + StepperCommandDialog = (await import('../StepperCommandDialog')).StepperCommandDialog; + StepperPanel = (await import('../StepperPanel')).StepperPanel; + const element = React.createElement( StepperCommandDialog, { @@ -108,17 +101,15 @@ describe('when a step contains a field with a validation error', () => { html = renderToStaticMarkup(element); }); - it('should_mark_the_invalid_step_with_error_class', () => { - // Step 1 has a field error — its number circle should have the red error background - const step1Match = html.match(/data-header="Step 1"[^>]*data-number-bg="([^"]*)"/); - const step1Bg = step1Match?.[1] ?? ''; - step1Bg.should.include('red'); + it('should_mark_the_invalid_step_number_with_the_error_color', () => { + // Step 1 (number "1") has a field error — its number circle should have the red error background + const step1Number = html.match(/]*>1<\/span>|
]*>1<\/div>/); + (step1Number?.[0] ?? '').should.include('red'); }); - it('should_not_mark_the_valid_step_with_error_class', () => { - // Step 2 has no field errors — its number circle should not have the red error background - const step2Match = html.match(/data-header="Step 2"[^>]*data-number-bg="([^"]*)"/); - const step2Bg = step2Match?.[1] ?? ''; - step2Bg.should.not.include('red'); + it('should_not_mark_the_valid_step_number_with_the_error_color', () => { + // Step 2 (number "2") has no field errors — its number circle should not have the red error background + const step2Number = html.match(/]*>2<\/span>|
]*>2<\/div>/); + (step2Number?.[0] ?? '').should.not.include('red'); }); }); diff --git a/Source/CommandDialog/index.ts b/Source/CommandDialog/index.ts index f28c863..c94ddde 100644 --- a/Source/CommandDialog/index.ts +++ b/Source/CommandDialog/index.ts @@ -5,3 +5,4 @@ export * from './applyBeforeExecute'; export * from './CommandDialog'; export * from './CommandStepper'; export * from './StepperCommandDialog'; +export * from './StepperPanel'; diff --git a/Source/Dialogs/Dialog.tsx b/Source/Dialogs/Dialog.tsx index 4775139..cb6bf1f 100644 --- a/Source/Dialogs/Dialog.tsx +++ b/Source/Dialogs/Dialog.tsx @@ -104,6 +104,16 @@ export interface DialogProps { /** Override the No button label. Defaults to `'No'`. */ noLabel?: string; + /** + * Whether the dialog can be dismissed via the header close button, a + * backdrop click, or the Escape key. When omitted, the dialog is + * dismissable exactly when a predefined {@link DialogButtons} set is used + * (a custom `ReactNode` footer or `null` footer is not dismissable by + * default). Set it explicitly to keep a dismiss affordance with a custom + * footer — as {@link StepperCommandDialog} does for its wizard chrome. + */ + dismissable?: boolean; + /** * Extra CSS class names forwarded to the underlying PrimeReact Dialog root. */ @@ -222,6 +232,7 @@ export const Dialog = ({ cancelLabel = 'Cancel', yesLabel = 'Yes', noLabel = 'No', + dismissable, className, pt, ptOptions, @@ -339,9 +350,11 @@ export const Dialog = ({ ); // The dialog is dismissable (backdrop click, Escape, header close button) - // only for the predefined-button sets, mirroring the v10 `closable` behavior - // that keyed off `typeof buttons === 'number'`. - const isDismissable = typeof buttons === 'number'; + // for the predefined-button sets by default, mirroring the v10 `closable` + // behavior that keyed off `typeof buttons === 'number'`. The `dismissable` + // prop overrides that default so a custom-footer dialog (e.g. the stepper + // wizard) can still offer a header-close affordance. + const isDismissable = dismissable ?? (typeof buttons === 'number'); // PrimeReact 11's Dialog is a controlled overlay: `open` reflects `visible`, // and any dismiss gesture fires `onOpenChange` with `value: false`. We route From 521acdac1714d5151b52bcee32005c560f64cc92 Mon Sep 17 00:00:00 2001 From: woksin Date: Thu, 16 Jul 2026 11:32:07 +0200 Subject: [PATCH 03/42] Reimplement Tooltip on PrimeReact 11 and migrate NameCell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 6 of the PrimeReact 11 migration. - Rebuild Common/Tooltip on PrimeReact 11's compositional Tooltip (Root/Trigger/Portal/Positioner/Popup), portaled to the document body so it is no longer clipped by overflow containers — the reason the old code reached for the (now removed) data-pr-tooltip directive. Public API (content/position/children) is preserved; content is now optional so `content={condition ? text : undefined}` renders the child with no tooltip, and a trigger className is accepted for full-width children. - Migrate SchemaEditor/NameCell off the removed data-pr-tooltip directive to the Tooltip wrapper. - Add a Tooltip Storybook story. The remaining data-pr-tooltip consumers (TypeCell, SchemaEditor, ObjectContentEditor) also depend on other removed v11 APIs (Dropdown, Button props, DataTable, Menubar, Calendar), so their tooltip migration ships with their full-file rebuilds in the DataTable and remaining-wrapper phases. Co-Authored-By: Claude Opus 4.8 (1M context) --- Source/Common/Tooltip.css | 13 +++++- Source/Common/Tooltip.stories.tsx | 47 +++++++++++++++++++ Source/Common/Tooltip.tsx | 76 ++++++++++++++++++++----------- Source/SchemaEditor/NameCell.tsx | 38 +++++++--------- 4 files changed, 126 insertions(+), 48 deletions(-) create mode 100644 Source/Common/Tooltip.stories.tsx diff --git a/Source/Common/Tooltip.css b/Source/Common/Tooltip.css index 0f9d6a3..2d964f9 100644 --- a/Source/Common/Tooltip.css +++ b/Source/Common/Tooltip.css @@ -1,10 +1,21 @@ /* Copyright (c) Cratis. All rights reserved. */ /* Licensed under the MIT license. See LICENSE file in the project root for full license information. */ +/* ── Tooltip trigger ─────────────────────────────────────────────────────── */ +.cratis-tooltip-trigger { + display: inline-flex; +} + /* ── Tooltip bubble ──────────────────────────────────────────────────────── */ -.tooltip-bubble { +.cratis-tooltip-popup { background: var(--cratis-surface-100); color: var(--cratis-text-color); border: 1px solid var(--cratis-surface-border); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); + border-radius: 0.25rem; + padding: 0.25rem 0.5rem; + font-size: 0.75rem; + font-family: system-ui, sans-serif; + max-width: 24rem; + z-index: 1100; } diff --git a/Source/Common/Tooltip.stories.tsx b/Source/Common/Tooltip.stories.tsx new file mode 100644 index 0000000..84e1787 --- /dev/null +++ b/Source/Common/Tooltip.stories.tsx @@ -0,0 +1,47 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import type { Meta, StoryObj } from '@storybook/react'; +import { Tooltip } from './Tooltip'; + +const meta = { + title: 'Common/Tooltip', + component: Tooltip, + parameters: { layout: 'centered' }, + tags: ['autodocs'], + args: { + content: 'A helpful hint shown on hover', + position: 'top', + children: Hover me, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +/** Hover the trigger to reveal a portaled tooltip. */ +export const Playground: Story = {}; + +/** One trigger per side to confirm portaled positioning. */ +export const Positions: Story = { + render: () => ( +
+ {(['top', 'right', 'bottom', 'left'] as const).map(position => ( + + + {position} + + + ))} +
+ ), +}; + +/** With no content the child renders on its own — no tooltip attached. */ +export const NoContent: Story = { + render: () => ( + + No tooltip here + + ), +}; diff --git a/Source/Common/Tooltip.tsx b/Source/Common/Tooltip.tsx index 3bfa617..488c38c 100644 --- a/Source/Common/Tooltip.tsx +++ b/Source/Common/Tooltip.tsx @@ -2,6 +2,7 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. import React from 'react'; +import { Tooltip as PrimeTooltip } from 'primereact/tooltip'; import './Tooltip.css'; /** Position of the tooltip relative to its trigger element. */ @@ -9,36 +10,59 @@ export type TooltipPosition = 'top' | 'right' | 'bottom' | 'left'; /** Props for the {@link Tooltip} component. */ export interface TooltipProps { - /** The text to display inside the tooltip. */ - content: string; + /** + * The text to display inside the tooltip. When empty or omitted, the + * children render on their own with no tooltip attached — convenient for + * the common `content={condition ? text : undefined}` pattern. + */ + content?: string; /** Where the tooltip appears relative to the trigger (default: 'top'). */ position?: TooltipPosition; - /** The element that triggers the tooltip on hover. */ + /** + * Extra class name(s) for the wrapping trigger element. The trigger is an + * inline-flex `` by default; pass `w-full` (or similar) when the + * child needs to fill its container. + */ + className?: string; + /** The element(s) that trigger the tooltip on hover. */ children: React.ReactNode; } -const POSITION_CLASSES: Record = { - right: 'left-full ml-2 top-1/2 -translate-y-1/2', - left: 'right-full mr-2 top-1/2 -translate-y-1/2', - top: 'bottom-full mb-2 left-1/2 -translate-x-1/2', - bottom: 'top-full mt-2 left-1/2 -translate-x-1/2', -}; - /** - * A CSS-only hover tooltip wrapper. Wraps any child element and displays - * a styled floating label on hover without relying on native browser tooltips. + * A hover tooltip wrapper around PrimeReact 11's compositional Tooltip. Wraps + * any child element and shows a floating label on hover, portaled to the + * document body so it is never clipped by an overflow container (table cells, + * dropdowns, scroll regions) — the reason this wrapper exists rather than a + * plain CSS `position: absolute` bubble. + * + * The public API (`content`, `position`, `children`) is preserved from the + * previous CSS-only implementation, so existing call sites are unaffected. + * This replaces the removed PrimeReact 10 `data-pr-tooltip` directive: wrap the + * trigger element instead of tagging it with a `data-pr-tooltip` attribute. + * + * ```tsx + * + * + * + * ``` */ -export const Tooltip: React.FC = ({ content, position = 'top', children }) => ( -
- {children} -
- {content} -
-
-); +export const Tooltip: React.FC = ({ content, position = 'top', className, children }) => { + if (!content) { + return <>{children}; + } + + return ( + + + {children} + + + + + {content} + + + + + ); +}; diff --git a/Source/SchemaEditor/NameCell.tsx b/Source/SchemaEditor/NameCell.tsx index c71ce4d..278777e 100644 --- a/Source/SchemaEditor/NameCell.tsx +++ b/Source/SchemaEditor/NameCell.tsx @@ -3,6 +3,7 @@ import { InputText } from 'primereact/inputtext'; import * as faIcons from 'react-icons/fa6'; +import { Tooltip } from '../Common/Tooltip'; import { JsonSchemaProperty } from '../types/JsonSchema'; export interface NameCellProps { @@ -21,33 +22,28 @@ export const NameCell = ({ rowData, isEditMode, onUpdate, validationError }: Nam return (
- - {rowData.name} - + + {rowData.name} + {rowData.description && ( - + + + )}
); } return ( - onUpdate(rowData.name || '', 'name', e.target.value)} - className="w-full" - invalid={!!validationError} - data-pr-tooltip={validationError} - data-pr-position="top" - /> + + onUpdate(rowData.name || '', 'name', e.target.value)} + className="w-full" + invalid={!!validationError} + /> + ); }; From b389a85a12f0c5f53a0758cc8244647bd3246427 Mon Sep 17 00:00:00 2001 From: woksin Date: Thu, 16 Jul 2026 11:48:04 +0200 Subject: [PATCH 04/42] Rebuild DataTables on PrimeReact 11 headless DataTable Phase 7 (part 1) of the PrimeReact 11 migration. PrimeReact 11's DataTable is a fully headless compositional table (Root only provides context; the scaffold is built by hand) and the monolithic v10 props are gone. Rebuild the Cratis data tables on it: - Add DataTableCore, a shared query-agnostic table that reads `` children and renders the v11 headless Root/Table/THead/TBody/Cell scaffold, including sortable headers and an empty-state body. Rows come in via `data` (v11 renamed the old `value` prop, which now means the sort field). - Translate v11's key-based selection (`Record`) back to the row-object selection API callers expect, via a new DataTableSelectionChangeEvent replacing the removed DataTableSelectionSingleChangeEvent. - Add a Cratis Column marker replacing the removed `primereact/column`, and a small TablePaginator (PrimeReact 11's Paginator is a headless slot; paging is still driven by Arc's paging hook). - Rebuild DataTableForQuery and DataTableForObservableQuery on DataTableCore + TablePaginator, preserving their public props (selection, dataKey, globalFilterFields, scrollable, pt/unstyled). Per-column filter-menu UI (which no column in the library opted into) is not carried over. - Retype pt/ptOptions off the v11 DataTableRootProps; export Column and the new event/filter types; update the two DataTable stories. Co-Authored-By: Claude Opus 4.8 (1M context) --- Source/DataTables/Column.tsx | 57 +++++ Source/DataTables/DataTableCore.tsx | 206 ++++++++++++++++++ Source/DataTables/DataTableFilterMeta.ts | 9 + .../DataTableForObservableQuery.stories.tsx | 6 +- .../DataTableForObservableQuery.tsx | 146 ++++--------- .../DataTables/DataTableForQuery.stories.tsx | 6 +- Source/DataTables/DataTableForQuery.tsx | 115 ++++------ .../DataTableSelectionChangeEvent.ts | 20 ++ Source/DataTables/TablePaginator.css | 16 ++ Source/DataTables/TablePaginator.tsx | 47 ++++ Source/DataTables/index.ts | 3 + 11 files changed, 450 insertions(+), 181 deletions(-) create mode 100644 Source/DataTables/Column.tsx create mode 100644 Source/DataTables/DataTableCore.tsx create mode 100644 Source/DataTables/DataTableFilterMeta.ts create mode 100644 Source/DataTables/DataTableSelectionChangeEvent.ts create mode 100644 Source/DataTables/TablePaginator.css create mode 100644 Source/DataTables/TablePaginator.tsx diff --git a/Source/DataTables/Column.tsx b/Source/DataTables/Column.tsx new file mode 100644 index 0000000..fd44e48 --- /dev/null +++ b/Source/DataTables/Column.tsx @@ -0,0 +1,57 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import React from 'react'; + +/** + * Props for {@link Column}. + * + * @typeParam TData - The row type the column's `body` template receives. + */ +export interface ColumnProps { + /** The row property this column reads by default (when no `body` is given). */ + field?: string; + /** The header content shown at the top of the column. */ + header?: React.ReactNode; + /** A custom cell renderer; receives the row for this cell. */ + body?: (rowData: TData) => React.ReactNode; + /** When true, the column header becomes a sort control. */ + sortable?: boolean; + /** + * Renders a selection control column (a radio for `single`, a checkbox for + * `multiple`) instead of a data column. + */ + selectionMode?: 'single' | 'multiple'; + /** Inline style for every body cell in the column. */ + style?: React.CSSProperties; + /** Class name for every body cell in the column. */ + className?: string; + /** Inline style for the column's header cell. */ + headerStyle?: React.CSSProperties; + /** Class name for the column's header cell. */ + headerClassName?: string; + /** Inline style applied on top of {@link style} for body cells. */ + bodyStyle?: React.CSSProperties; + /** Class name applied on top of {@link className} for body cells. */ + bodyClassName?: string; +} + +/** + * Declares one column of a Cratis data table (`DataTableForQuery`, + * `DataTableForObservableQuery`, `DataPage.Columns`). This is the Cratis-owned + * replacement for the removed `primereact/column` — the same `` authoring model, mapped internally onto PrimeReact + * 11's headless DataTable header/body cells. + * + * It is a pure marker: the surrounding table reads its props to build the + * header and per-row cells, so it renders nothing when mounted on its own. + * + * ```tsx + * + * + * + * + * ``` + */ +export const Column = (_props: ColumnProps): React.ReactElement | null => null; +Column.displayName = 'Column'; diff --git a/Source/DataTables/DataTableCore.tsx b/Source/DataTables/DataTableCore.tsx new file mode 100644 index 0000000..c087129 --- /dev/null +++ b/Source/DataTables/DataTableCore.tsx @@ -0,0 +1,206 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import React, { useMemo, type CSSProperties, type ReactNode } from 'react'; +import { DataTable as PrimeDataTable } from 'primereact/datatable'; +import type { DataTableRootProps } from '@primereact/types/primitive/datatable'; +import type { SelectionKeys, UseDataTableSelectionEvent, UseDataTableRowMouseEvent } from '@primereact/types/headless/datatable'; +import type { ColumnProps } from './Column'; +import type { DataTableSelectionChangeEvent } from './DataTableSelectionChangeEvent'; + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +/** + * Row-click event surfaced by {@link DataTableCore}. + * + * @typeParam TData - The row type. + */ +export interface DataTableRowClickEvent { + /** The clicked row. */ + data: TData; + /** The row's index in the current page. */ + index: number; +} + +/** + * Props for {@link DataTableCore}. + * + * @typeParam TData - The row type. + */ +export interface DataTableCoreProps { + /** The rows to render (already paged by the caller). */ + data: TData[]; + /** `` elements describing the columns. */ + children?: ReactNode; + /** The row property uniquely identifying each row — required for selection. */ + dataKey?: string; + /** Content shown when there are no rows. */ + emptyMessage: ReactNode; + /** Enables single-row selection by clicking a row. */ + selectionMode?: 'single'; + /** The currently-selected row. */ + selection?: TData | null; + /** Invoked when the selected row changes. */ + onSelectionChange?: (event: DataTableSelectionChangeEvent) => void; + /** Invoked when a row is clicked. */ + onRowClick?: (event: DataTableRowClickEvent) => void; + /** Computes an extra class name for each row. */ + rowClassName?: (rowData: TData) => string; + /** A global filter term applied across {@link globalFilterFields}. */ + globalFilter?: string | null; + /** The fields the {@link globalFilter} term is matched against. */ + globalFilterFields?: string[]; + /** Renders the table body in a scroll region of {@link scrollHeight}. */ + scrollable?: boolean; + /** The height of the scroll region when {@link scrollable} is set. */ + scrollHeight?: string; + /** Extra class name for the table root. */ + className?: string; + /** Inline style for the table root. */ + style?: CSSProperties; + /** PrimeReact pass-through configuration for the underlying DataTable. */ + pt?: DataTableRootProps['pt']; + /** PrimeReact pass-through options for the underlying DataTable. */ + ptOptions?: DataTableRootProps['ptOptions']; + /** When true, disables every base PrimeReact style on the DataTable. */ + unstyled?: boolean; +} + +/** Reads the parsed column definitions from `` children. */ +const useColumns = (children: ReactNode): React.ReactElement>[] => + useMemo( + () => React.Children.toArray(children).filter(React.isValidElement) as React.ReactElement>[], + [children] + ); + +const renderCellContent = (column: ColumnProps, row: object): ReactNode => { + if (column.body) return column.body(row); + if (column.field) { + const value = (row as Record)[column.field]; + return value == null ? '' : String(value); + } + return null; +}; + +/** + * The shared, query-agnostic table used by {@link DataTableForQuery}, + * {@link DataTableForObservableQuery}, and the schema editor. Rebuilds + * PrimeReact 10's monolithic `DataTable` on PrimeReact 11's headless + * compositional table: it reads `` children, renders the header and + * per-row body cells, and translates PrimeReact 11's key-based selection back + * to the row-object API callers expect. + * + * Paging is intentionally *not* handled here — the query wrappers own paging + * through Arc and feed one page of rows in via `data`, so this component only + * renders and sorts/filters the current page client-side. + * + * @typeParam TData - The row type. + */ +export const DataTableCore = ({ + data, + children, + dataKey, + emptyMessage, + selectionMode, + selection, + onSelectionChange, + onRowClick, + rowClassName, + globalFilter, + globalFilterFields, + scrollable, + scrollHeight, + className, + style, + pt, + ptOptions, + unstyled, +}: DataTableCoreProps) => { + const columns = useColumns(children); + + const keyOf = (row: TData): string | undefined => + dataKey ? String((row as Record)[dataKey]) : undefined; + + const selectionKeys: SelectionKeys = useMemo(() => { + if (!selection || !dataKey) return {}; + const key = String((selection as Record)[dataKey]); + return { [key]: true }; + }, [selection, dataKey]); + + const handleSelectionChange = (event: UseDataTableSelectionEvent) => { + if (!onSelectionChange) return; + const selectedKey = Object.keys(event.value).find(key => event.value[key]); + const row = selectedKey !== undefined + ? data.find(candidate => keyOf(candidate) === selectedKey) ?? null + : null; + onSelectionChange({ value: row, originalEvent: event.originalEvent }); + }; + + const handleRowClick = onRowClick + ? (event: UseDataTableRowMouseEvent) => onRowClick({ data: event.data as TData, index: event.index }) + : undefined; + + return ( + + + + + + {columns.map((column, index) => ( + + {column.props.sortable && column.props.field ? ( + + {column.props.header} + + + + ) : ( + column.props.header + )} + + ))} + + + + {({ item, index }) => ( + + {columns.map((column, columnIndex) => ( + + {renderCellContent(column.props, item)} + + ))} + + )} + + + + {emptyMessage} + + + + + + ); +}; diff --git a/Source/DataTables/DataTableFilterMeta.ts b/Source/DataTables/DataTableFilterMeta.ts new file mode 100644 index 0000000..fedc4af --- /dev/null +++ b/Source/DataTables/DataTableFilterMeta.ts @@ -0,0 +1,9 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +/** + * Filter state for a Cratis data table, keyed by field name. Kept as a loose + * record so callers can seed saved/URL-encoded filter state; replaces + * PrimeReact 10's `DataTableFilterMeta` (removed in PrimeReact 11). + */ +export type DataTableFilterMeta = Record; diff --git a/Source/DataTables/DataTableForObservableQuery.stories.tsx b/Source/DataTables/DataTableForObservableQuery.stories.tsx index 3f15757..3731994 100644 --- a/Source/DataTables/DataTableForObservableQuery.stories.tsx +++ b/Source/DataTables/DataTableForObservableQuery.stories.tsx @@ -4,9 +4,9 @@ import React, { useState } from 'react'; import { Meta, StoryObj } from '@storybook/react'; import { DataTableForObservableQuery } from './DataTableForObservableQuery'; -import { Column } from 'primereact/column'; +import { Column } from './Column'; import { ObservableQueryFor, QueryResult, ObservableQuerySubscription } from '@cratis/arc/queries'; -import { DataTableSelectionSingleChangeEvent } from 'primereact/datatable'; +import type { DataTableSelectionChangeEvent } from './DataTableSelectionChangeEvent'; const meta: Meta = { title: 'DataTables/DataTableForObservableQuery', @@ -138,7 +138,7 @@ export const WithSelection: Story = { emptyMessage="No tasks found" dataKey="id" selection={selectedTask} - onSelectionChange={(e: DataTableSelectionSingleChangeEvent) => setSelectedTask(e.value as Task)} + onSelectionChange={(e: DataTableSelectionChangeEvent) => setSelectedTask(e.value ?? undefined)} > diff --git a/Source/DataTables/DataTableForObservableQuery.tsx b/Source/DataTables/DataTableForObservableQuery.tsx index 5a9008d..5261975 100644 --- a/Source/DataTables/DataTableForObservableQuery.tsx +++ b/Source/DataTables/DataTableForObservableQuery.tsx @@ -1,12 +1,15 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. -import { DataTable, DataTableFilterMeta, DataTableSelectionSingleChangeEvent, type DataTableProps as PrimeDataTableProps } from 'primereact/datatable'; -import { Paginator, type PaginatorProps } from 'primereact/paginator'; +import type { DataTableRootProps } from '@primereact/types/primitive/datatable'; import { Constructor } from '@cratis/fundamentals'; import { IObservableQueryFor, Paging } from '@cratis/arc/queries'; import { useObservableQueryWithPaging } from '@cratis/arc.react/queries'; import { ReactNode, useState, useRef, useEffect } from 'react'; +import { DataTableCore } from './DataTableCore'; +import { TablePaginator } from './TablePaginator'; +import type { DataTableFilterMeta } from './DataTableFilterMeta'; +import type { DataTableSelectionChangeEvent } from './DataTableSelectionChangeEvent'; /** * Props for {@link DataTableForObservableQuery}. @@ -17,7 +20,7 @@ import { ReactNode, useState, useRef, useEffect } from 'react'; */ export interface DataTableForObservableQueryProps, TDataType extends object, TArguments extends object> { /** - * Children to render + * Children to render — `` elements describing the visible columns. */ children?: ReactNode; @@ -49,7 +52,7 @@ export interface DataTableForObservableQueryProps): void; + onSelectionChange?(event: DataTableSelectionChangeEvent): void; /** * Fields to use for global filtering @@ -67,104 +70,64 @@ export interface DataTableForObservableQueryProps['pt']; + pt?: DataTableRootProps['pt']; /** PrimeReact pass-through options applied to the underlying DataTable. */ - ptOptions?: PrimeDataTableProps['ptOptions']; + ptOptions?: DataTableRootProps['ptOptions']; /** When true, disables every base PrimeReact style on the underlying DataTable. */ unstyled?: boolean; - /** PrimeReact pass-through configuration applied to the inner Paginator. */ - paginatorPt?: PaginatorProps['pt']; - - /** PrimeReact pass-through options applied to the inner Paginator. */ - paginatorPtOptions?: PaginatorProps['ptOptions']; - - /** When true, disables every base PrimeReact style on the inner Paginator. */ - paginatorUnstyled?: boolean; + /** Extra CSS class name forwarded to the paginator. */ + paginatorClassName?: string; } const paging = new Paging(0, 20); /** - * A paged data table bound to a Cratis Arc **observable** query + * A paged data table bound to a real-time Cratis Arc observable query * (`IObservableQueryFor`). Subscribes via - * `useObservableQueryWithPaging` from `@cratis/arc.react/queries`, which - * opens a WebSocket connection to the backend and re-renders the table - * automatically whenever the underlying read model changes server-side. - * - * ## What `TQuery` is - * - * `TQuery` is the auto-generated TypeScript class produced by the Arc proxy - * generator from a C# read model's static observable query method (one - * that returns `ISubject` on the backend). The proxy hooks the - * WebSocket subscription up; you only deal with the resulting React data. - * - * ## What's unique - * - * - **Real-time updates**: server-side projection writes flow into the - * table within the same render cycle, with no manual polling or refresh - * button. The connection re-subscribes automatically when - * `queryArguments` change. - * - **Resize-aware scrollable height**: the wrapper observes its container - * via `ResizeObserver` and adjusts the inner DataTable height to fit, - * so the table behaves correctly inside flex layouts (it never overflows - * beyond its parent or shrinks below 200px). - * - **Lazy paging + optional client filtering**: same modes as - * {@link DataTableForQuery}, but the page is re-issued through the - * observable subscription when the user pages. - * - * Use {@link DataTableForQuery} for snapshot queries that don't need live - * updates. Use {@link DataPage} for a higher-level layout that combines - * this table with a menubar, selection, and a details pane. + * `useObservableQueryWithPaging`, so the table re-renders automatically as the + * underlying read model changes server-side. Rows render through the headless + * {@link DataTableCore} inside an internally-scrolling region that resizes to + * fill its container. * * ## Children * - * Children are PrimeReact `` elements — same as - * {@link DataTableForQuery}. + * Children are Cratis `` elements describing the visible columns. * * ```tsx - * import { DataTableForObservableQuery } from '@cratis/components/DataTables'; - * import { Column } from 'primereact/column'; - * import { ActiveSessions } from './ActiveSessions'; // proxy from C# + * import { DataTableForObservableQuery, Column } from '@cratis/components/DataTables'; + * import { AllTasks } from './AllTasks'; // observable proxy from C# * - * - * - * + * + * + * * * ``` * - * Rows appear and disappear in this table as users log in / log out - * server-side — no manual refresh required. - * - * ## Styling - * - * Identical to {@link DataTableForQuery}: `pt` / `ptOptions` / `unstyled` / - * `className` target the inner DataTable; `paginatorPt` and friends target - * the inner Paginator. See [pass-through cheat sheet](../../Documentation/Styling/pass-through.md). + * Use {@link DataTableForQuery} for one-shot snapshot queries. Use + * {@link DataPage} for a higher-level layout that combines this table with + * an action menubar, selection, and a details pane. * - * @typeParam TQuery - The observable query class (proxy generated from C# `IObservableQueryFor`). + * @typeParam TQuery - The query class (proxy generated from C# `IObservableQueryFor`). * @typeParam TDataType - The row type returned by the query. * @typeParam TArguments - The query's argument object type. * @param props - {@link DataTableForObservableQueryProps}. */ export const DataTableForObservableQuery = , TDataType extends object, TArguments extends object>(props: DataTableForObservableQueryProps) => { - const [filters, setFilters] = useState(props.defaultFilters ?? {}); - const [filteredTotal, setFilteredTotal] = useState(undefined); const [result, , setPage] = useObservableQueryWithPaging(props.query, paging, props.queryArguments); const containerRef = useRef(null); const [tableHeight, setTableHeight] = useState(600); const timeoutRef = useRef | undefined>(undefined); - const isClientFiltering = props.clientFiltering === true; - const totalRecords = isClientFiltering && filteredTotal !== undefined ? filteredTotal : result.paging.totalItems; + const totalItems = result.paging.totalItems; + const pageCount = result.paging.totalPages; + const showPaginator = totalItems > 0 && pageCount > 1; useEffect(() => { if (!containerRef.current) return; @@ -178,7 +141,7 @@ export const DataTableForObservableQuery = 0) { - const paginatorHeight = result.paging.totalItems > 0 ? 56 : 0; + const paginatorHeight = showPaginator ? 56 : 0; const calculatedHeight = containerHeight - paginatorHeight - 2; const newHeight = Math.max(calculatedHeight, 200); @@ -201,7 +164,7 @@ export const DataTableForObservableQuery =
- + data={result.data as unknown as TDataType[]} + dataKey={props.dataKey} + emptyMessage={props.emptyMessage} selectionMode='single' selection={props.selection} onSelectionChange={props.onSelectionChange} - dataKey={props.dataKey} - filters={filters} - filterDisplay='menu' - onFilter={(e) => { - setFilters(e.filters); - if (isClientFiltering) { - const filteredValue = e.filteredValue as unknown[] | undefined; - setFilteredTotal(filteredValue ? filteredValue.length : undefined); - } - }} globalFilterFields={props.globalFilterFields} - emptyMessage={props.emptyMessage} + scrollable + scrollHeight='100%' className={props.className} + style={{ minWidth: '100%' }} pt={props.pt} ptOptions={props.ptOptions} unstyled={props.unstyled}> {props.children} - +
- {result.paging.totalItems > 0 && ( -
- setPage(e.page)} - pt={props.paginatorPt} - ptOptions={props.paginatorPtOptions} - unstyled={props.paginatorUnstyled} + + {showPaginator && ( +
+
)} diff --git a/Source/DataTables/DataTableForQuery.stories.tsx b/Source/DataTables/DataTableForQuery.stories.tsx index 85020ad..a844163 100644 --- a/Source/DataTables/DataTableForQuery.stories.tsx +++ b/Source/DataTables/DataTableForQuery.stories.tsx @@ -4,9 +4,9 @@ import React, { useState } from 'react'; import { Meta, StoryObj } from '@storybook/react'; import { DataTableForQuery } from './DataTableForQuery'; -import { Column } from 'primereact/column'; +import { Column } from './Column'; import { QueryFor, QueryResult } from '@cratis/arc/queries'; -import { DataTableSelectionSingleChangeEvent } from 'primereact/datatable'; +import type { DataTableSelectionChangeEvent } from './DataTableSelectionChangeEvent'; const meta: Meta = { title: 'DataTables/DataTableForQuery', @@ -107,7 +107,7 @@ export const WithSelection: Story = { emptyMessage="No products found" dataKey="id" selection={selectedProduct} - onSelectionChange={(e: DataTableSelectionSingleChangeEvent) => setSelectedProduct(e.value as Product)} + onSelectionChange={(e: DataTableSelectionChangeEvent) => setSelectedProduct(e.value ?? undefined)} > diff --git a/Source/DataTables/DataTableForQuery.tsx b/Source/DataTables/DataTableForQuery.tsx index 1ff309b..d3c6b0c 100644 --- a/Source/DataTables/DataTableForQuery.tsx +++ b/Source/DataTables/DataTableForQuery.tsx @@ -1,12 +1,15 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. -import { DataTable, DataTableFilterMeta, DataTableSelectionSingleChangeEvent, type DataTableProps as PrimeDataTableProps } from 'primereact/datatable'; -import { Paginator, type PaginatorProps } from 'primereact/paginator'; +import type { DataTableRootProps } from '@primereact/types/primitive/datatable'; import { Constructor } from '@cratis/fundamentals'; import { IQueryFor, Paging } from '@cratis/arc/queries'; import { useQueryWithPaging } from '@cratis/arc.react/queries'; -import { ReactNode, useState, useRef } from 'react'; +import { ReactNode } from 'react'; +import { DataTableCore } from './DataTableCore'; +import { TablePaginator } from './TablePaginator'; +import type { DataTableFilterMeta } from './DataTableFilterMeta'; +import type { DataTableSelectionChangeEvent } from './DataTableSelectionChangeEvent'; /** * Props for {@link DataTableForQuery}. @@ -17,7 +20,7 @@ import { ReactNode, useState, useRef } from 'react'; */ export interface DataTableForQueryProps, TDataType extends object, TArguments extends object> { /** - * Children to render + * Children to render — `` elements describing the visible columns. */ children?: ReactNode; @@ -49,7 +52,7 @@ export interface DataTableForQueryProps): void; + onSelectionChange?(event: DataTableSelectionChangeEvent): void; /** * Fields to use for global filtering @@ -67,27 +70,21 @@ export interface DataTableForQueryProps['pt']; + pt?: DataTableRootProps['pt']; /** PrimeReact pass-through options applied to the underlying DataTable. */ - ptOptions?: PrimeDataTableProps['ptOptions']; + ptOptions?: DataTableRootProps['ptOptions']; /** When true, disables every base PrimeReact style on the underlying DataTable. */ unstyled?: boolean; - /** PrimeReact pass-through configuration applied to the inner Paginator. */ - paginatorPt?: PaginatorProps['pt']; - - /** PrimeReact pass-through options applied to the inner Paginator. */ - paginatorPtOptions?: PaginatorProps['ptOptions']; - - /** When true, disables every base PrimeReact style on the inner Paginator. */ - paginatorUnstyled?: boolean; + /** Extra CSS class name forwarded to the paginator. */ + paginatorClassName?: string; } const paging = new Paging(0, 20); @@ -96,8 +93,8 @@ const paging = new Paging(0, 20); * A paged data table bound to a snapshot Cratis Arc query * (`IQueryFor`). Subscribes via * `useQueryWithPaging` from `@cratis/arc.react/queries`, renders the result - * page in a PrimeReact `DataTable`, and shows a `Paginator` when the result - * set exceeds one page. + * page through the headless {@link DataTableCore}, and shows a + * {@link TablePaginator} when the result set exceeds one page. * * ## What `TQuery` is * @@ -106,33 +103,13 @@ const paging = new Paging(0, 20); * writes a `.ts` file per query with the right return type and a `use()` * hook; importing the class is all the connection-to-the-backend you need. * - * ## What's unique - * - * - **Lazy paging**: the table runs in PrimeReact's `lazy` mode by default - * so the server returns one page at a time. The Paginator's - * `onPageChange` calls back into the Arc hook to fetch the next page. - * - **Client-side filtering toggle**: pass `clientFiltering` to keep the - * page-fetched rows in the browser and filter locally — useful for - * small result sets where you want PrimeReact's filter UI but don't want - * to round-trip every keystroke. - * - **Default filter state**: `defaultFilters` seeds the table's filter - * meta on first render so saved or URL-encoded filter state can be - * rehydrated. - * - * Use {@link DataTableForObservableQuery} for queries that should update in - * real time as the underlying read model changes server-side. Use - * {@link DataPage} for a higher-level layout that combines this table with - * a menubar, selection, and a details pane. - * * ## Children * - * Children are PrimeReact `` elements describing the visible - * columns — sorting, filtering, custom body templates, everything - * PrimeReact's `` supports. + * Children are Cratis `` elements describing the visible columns — + * `field`, `header`, custom `body` templates, and `sortable`. * * ```tsx - * import { DataTableForQuery } from '@cratis/components/DataTables'; - * import { Column } from 'primereact/column'; + * import { DataTableForQuery, Column } from '@cratis/components/DataTables'; * import { AllAuthors } from './AllAuthors'; // proxy from C# * * @@ -141,12 +118,15 @@ const paging = new Paging(0, 20); * * ``` * + * Use {@link DataTableForObservableQuery} for queries that should update in + * real time as the underlying read model changes server-side. Use + * {@link DataPage} for a higher-level layout that combines this table with + * an action menubar, selection, and a details pane. + * * ## Styling * * Forward `pt` / `ptOptions` / `unstyled` / `className` to the underlying - * PrimeReact DataTable. Use `paginatorPt` / `paginatorPtOptions` / - * `paginatorUnstyled` to style the inner Paginator independently. See - * [pass-through cheat sheet](../../Documentation/Styling/pass-through.md). + * DataTable. See [pass-through cheat sheet](../../Documentation/Styling/pass-through.md). * * @typeParam TQuery - The query class (proxy generated from C# `IQueryFor`). * @typeParam TDataType - The row type returned by the query. @@ -154,16 +134,12 @@ const paging = new Paging(0, 20); * @param props - {@link DataTableForQueryProps}. */ export const DataTableForQuery = , TDataType extends object, TArguments extends object>(props: DataTableForQueryProps) => { - const [filters, setFilters] = useState(props.defaultFilters ?? {}); - const [filteredTotal, setFilteredTotal] = useState(undefined); const [result, , , setPage] = useQueryWithPaging(props.query, paging, props.queryArguments); - const containerRef = useRef(null); - const isClientFiltering = props.clientFiltering === true; - const totalRecords = isClientFiltering && filteredTotal !== undefined ? filteredTotal : result.paging.totalItems; + const totalItems = result.paging.totalItems; + const pageCount = result.paging.totalPages; return (
- + data={result.data as unknown as TDataType[]} + dataKey={props.dataKey} + emptyMessage={props.emptyMessage} selectionMode='single' selection={props.selection} onSelectionChange={props.onSelectionChange} - dataKey={props.dataKey} - filters={filters} - filterDisplay='menu' - onFilter={(e) => { - setFilters(e.filters); - if (isClientFiltering) { - const filteredValue = e.filteredValue as unknown[] | undefined; - setFilteredTotal(filteredValue ? filteredValue.length : undefined); - } - }} globalFilterFields={props.globalFilterFields} - emptyMessage={props.emptyMessage} - style={{ minWidth: '100%' }} className={props.className} + style={{ minWidth: '100%' }} pt={props.pt} ptOptions={props.ptOptions} unstyled={props.unstyled}> {props.children} - +
- {result.paging.totalItems > 0 && ( + {totalItems > 0 && pageCount > 1 && (
- setPage(e.page)} - pt={props.paginatorPt} - ptOptions={props.paginatorPtOptions} - unstyled={props.paginatorUnstyled} +
)} diff --git a/Source/DataTables/DataTableSelectionChangeEvent.ts b/Source/DataTables/DataTableSelectionChangeEvent.ts new file mode 100644 index 0000000..163e9d9 --- /dev/null +++ b/Source/DataTables/DataTableSelectionChangeEvent.ts @@ -0,0 +1,20 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import type { SyntheticEvent } from 'react'; + +/** + * Fired when the selected row of a Cratis data table changes. Replaces + * PrimeReact 10's `DataTableSelectionSingleChangeEvent` (removed in + * PrimeReact 11, which models selection by key rather than by row object). + * The `value` field is preserved so existing `event.value` call sites keep + * working. + * + * @typeParam TData - The row type. + */ +export interface DataTableSelectionChangeEvent { + /** The newly-selected row, or `null` when the selection was cleared. */ + value: TData | null; + /** The DOM event that triggered the change, when available. */ + originalEvent?: SyntheticEvent; +} diff --git a/Source/DataTables/TablePaginator.css b/Source/DataTables/TablePaginator.css new file mode 100644 index 0000000..6f7f207 --- /dev/null +++ b/Source/DataTables/TablePaginator.css @@ -0,0 +1,16 @@ +/* Copyright (c) Cratis. All rights reserved. */ +/* Licensed under the MIT license. See LICENSE file in the project root for full license information. */ + +.cratis-table-paginator { + display: flex; + align-items: center; + justify-content: center; + gap: 0.25rem; + padding: 0.25rem; +} + +.cratis-table-paginator-info { + font-size: 0.875rem; + color: var(--cratis-text-color-secondary); + padding: 0 0.5rem; +} diff --git a/Source/DataTables/TablePaginator.tsx b/Source/DataTables/TablePaginator.tsx new file mode 100644 index 0000000..9141c9b --- /dev/null +++ b/Source/DataTables/TablePaginator.tsx @@ -0,0 +1,47 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { Button } from 'primereact/button'; +import './TablePaginator.css'; + +/** Props for {@link TablePaginator}. */ +export interface TablePaginatorProps { + /** The current page, zero-based. */ + page: number; + /** The total number of pages. */ + pageCount: number; + /** Invoked with the requested zero-based page. */ + onPageChange: (page: number) => void; + /** Extra class name for the paginator container. */ + className?: string; +} + +/** + * A minimal first/previous/next/last paginator. PrimeReact 11's `Paginator` + * is a headless compositional slot rather than a ready-made control, and the + * Cratis data tables drive paging through Arc's paging hook rather than the + * table's internal pagination — so this renders the page controls wired to a + * simple `onPageChange(pageIndex)` callback. + */ +export const TablePaginator = ({ page, pageCount, onPageChange, className }: TablePaginatorProps) => { + const isFirst = page <= 0; + const isLast = page >= pageCount - 1; + + return ( +
+ + + {page + 1} / {Math.max(pageCount, 1)} + + +
+ ); +}; diff --git a/Source/DataTables/index.ts b/Source/DataTables/index.ts index c9d4a93..30c1093 100644 --- a/Source/DataTables/index.ts +++ b/Source/DataTables/index.ts @@ -3,3 +3,6 @@ export * from './DataTableForQuery'; export * from './DataTableForObservableQuery'; +export * from './Column'; +export * from './DataTableSelectionChangeEvent'; +export * from './DataTableFilterMeta'; From ec23803b6c755a1ebd186c4f4e8ffe38dde943d4 Mon Sep 17 00:00:00 2001 From: woksin Date: Thu, 16 Jul 2026 11:56:00 +0200 Subject: [PATCH 05/42] Migrate DataPage and SchemaEditor to the v11 table and action menubar Phase 7 (part 2) of the PrimeReact 11 migration. - Add ActionMenubar, a small command toolbar that replaces the removed PrimeReact Menubar. PrimeReact 11 has no menubar and NavigationMenu is built for navigation, not command actions, so a focused button bar driven by the same `model` shape is the better fit for these action bars. - Migrate DataPage onto ActionMenubar and the headless DataTables, replacing the removed DataTableSelectionSingleChangeEvent / DataTableFilterMeta / DataTableProps types with the Cratis equivalents, and re-export Column from the DataPage subpath. - Rebuild SchemaEditor on DataTableCore + ActionMenubar + the Cratis Tooltip wrapper, and convert its and TypeCell's buttons to PrimeReact 11 (children + variant/iconOnly), TypeCell's dropdowns to the Cratis Dropdown, and both files' tooltips off the removed data-pr-tooltip directive. Co-Authored-By: Claude Opus 4.8 (1M context) --- Source/Common/ActionMenubar.css | 9 +++ Source/Common/ActionMenubar.tsx | 67 ++++++++++++++++++ Source/DataPage/DataPage.stories.tsx | 8 +-- Source/DataPage/DataPage.tsx | 75 +++++++++----------- Source/DataPage/index.ts | 1 + Source/SchemaEditor/SchemaEditor.tsx | 65 ++++++++--------- Source/SchemaEditor/TypeCell.tsx | 101 ++++++++++++--------------- 7 files changed, 190 insertions(+), 136 deletions(-) create mode 100644 Source/Common/ActionMenubar.css create mode 100644 Source/Common/ActionMenubar.tsx diff --git a/Source/Common/ActionMenubar.css b/Source/Common/ActionMenubar.css new file mode 100644 index 0000000..fb6eb0e --- /dev/null +++ b/Source/Common/ActionMenubar.css @@ -0,0 +1,9 @@ +/* Copyright (c) Cratis. All rights reserved. */ +/* Licensed under the MIT license. See LICENSE file in the project root for full license information. */ + +.cratis-action-menubar { + display: flex; + align-items: center; + gap: 0.25rem; + flex-wrap: wrap; +} diff --git a/Source/Common/ActionMenubar.tsx b/Source/Common/ActionMenubar.tsx new file mode 100644 index 0000000..c9bc5e4 --- /dev/null +++ b/Source/Common/ActionMenubar.tsx @@ -0,0 +1,67 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import React from 'react'; +import { Button } from 'primereact/button'; +import './ActionMenubar.css'; + +/** A single action in an {@link ActionMenubar}. */ +export interface ActionMenuItem { + /** The visible label. */ + label?: string; + /** An icon element rendered before the label. */ + icon?: React.ReactNode; + /** Invoked when the item is activated. */ + command?: () => void; + /** When true, the item is greyed out and not clickable. */ + disabled?: boolean; + /** Extra class name for the item. */ + className?: string; + /** Severity styling for the underlying button (e.g. `danger`). */ + severity?: 'secondary' | 'info' | 'success' | 'warn' | 'help' | 'danger' | 'contrast'; + /** Fully custom render for this item; when present it replaces the default button. */ + template?: (item: ActionMenuItem) => React.ReactNode; +} + +/** Props for {@link ActionMenubar}. */ +export interface ActionMenubarProps { + /** The actions to render, left to right. */ + model: ActionMenuItem[]; + /** Extra class name for the toolbar container. */ + className?: string; + /** Accessible label for the toolbar. */ + 'aria-label'?: string; +} + +/** + * A horizontal bar of command actions. Replaces the PrimeReact 10 `Menubar` + * (removed in PrimeReact 11, and never a great fit for a bar of *actions* + * rather than navigation) with a simple button toolbar driven by the same + * `model` array shape. Each item is a text `Button` unless it supplies a + * `template`. + */ +export const ActionMenubar = ({ model, className, ...rest }: ActionMenubarProps) => ( +
+ {model.map((item, index) => { + if (item.template) { + return {item.template(item)}; + } + + return ( + + ); + })} +
+); diff --git a/Source/DataPage/DataPage.stories.tsx b/Source/DataPage/DataPage.stories.tsx index 4c54200..cea7380 100644 --- a/Source/DataPage/DataPage.stories.tsx +++ b/Source/DataPage/DataPage.stories.tsx @@ -4,7 +4,7 @@ import React from 'react'; import { Meta, StoryObj } from '@storybook/react'; import { DataPage, MenuItem } from './DataPage'; -import { Column } from 'primereact/column'; +import { Column } from '../DataTables/Column'; import { QueryFor, QueryResult } from '@cratis/arc/queries'; const meta: Meta = { @@ -168,9 +168,9 @@ export const WithClientFiltering: Story = { > - - - + + +
diff --git a/Source/DataPage/DataPage.tsx b/Source/DataPage/DataPage.tsx index 3ce4226..a7ddb5e 100644 --- a/Source/DataPage/DataPage.tsx +++ b/Source/DataPage/DataPage.tsx @@ -4,22 +4,31 @@ import { ReactNode, useMemo } from 'react'; import { Page } from '../Common/Page'; import React from 'react'; -import { MenuItem as PrimeMenuItem } from 'primereact/menuitem'; -import { Menubar, type MenubarProps } from 'primereact/menubar'; +import { ActionMenubar, type ActionMenuItem } from '../Common/ActionMenubar'; import { IObservableQueryFor, IQueryFor, QueryFor } from '@cratis/arc/queries'; import { DataTableForObservableQuery } from '../DataTables/DataTableForObservableQuery'; -import { DataTableFilterMeta, DataTableSelectionSingleChangeEvent, type DataTableProps as PrimeDataTableProps } from 'primereact/datatable'; +import type { DataTableRootProps } from '@primereact/types/primitive/datatable'; import { DataTableForQuery } from '../DataTables/DataTableForQuery'; +import type { DataTableFilterMeta } from '../DataTables/DataTableFilterMeta'; +import type { DataTableSelectionChangeEvent } from '../DataTables/DataTableSelectionChangeEvent'; import { Allotment } from 'allotment'; import { Constructor } from '@cratis/fundamentals'; /* eslint-disable @typescript-eslint/no-explicit-any */ /** - * Props for {@link MenuItem}. Extends PrimeReact's `MenuItem` shape with one - * Cratis-specific flag. + * Props for {@link MenuItem} — a single action in a {@link DataPage}'s + * action bar. */ -export interface MenuItemProps extends PrimeMenuItem { +export interface MenuItemProps { + /** Icon component rendered before the label (e.g. a react-icons icon). */ + icon?: React.ComponentType<{ className?: string }>; + /** The visible label. */ + label?: string; + /** Invoked when the item is activated. */ + command?: () => void; + /** When true, the item is greyed out regardless of selection. */ + disabled?: boolean; /** * When true, the menu item is disabled while no row is selected in the * surrounding {@link DataPage}. Use it for context-sensitive actions like @@ -31,9 +40,8 @@ export interface MenuItemProps extends PrimeMenuItem { /** * Declarative menu item for use inside ``. Renders nothing * directly; the surrounding {@link MenuItems} component reads its props and - * forwards them to the action `Menubar`. + * forwards them to the action menubar. */ -// eslint-disable-next-line @typescript-eslint/no-unused-vars export const MenuItem = (_: MenuItemProps) => { return null; }; @@ -63,36 +71,28 @@ export interface ColumnProps { */ export const MenuItems = ({ children }: MenuItemsProps) => { const context = useDataPageContext(); - - const isDisabled = useMemo(() => { - return !context.selectedItem; - }, [context.selectedItem]); + const isDisabled = !context.selectedItem; const items = useMemo(() => { - const menuItems: PrimeMenuItem[] = []; + const menuItems: ActionMenuItem[] = []; React.Children.forEach(children, (child) => { - if (React.isValidElement(child) && child.type == MenuItem) { + if (React.isValidElement(child) && child.type === MenuItem) { const Icon = child.props.icon; - const menuItem = { ...child.props }; - menuItem.icon = ; - menuItem.disabled = isDisabled && child.props.disableOnUnselected; - menuItems.push(menuItem); + menuItems.push({ + label: child.props.label, + command: child.props.command, + icon: Icon ? : undefined, + disabled: (child.props.disabled ?? false) || (isDisabled && (child.props.disableOnUnselected ?? false)), + }); } }); return menuItems; - }, [children, context.selectedItem]); + }, [children, isDisabled]); return (
- +
); }; @@ -159,7 +159,7 @@ export interface IDetailsComponentProps { interface IDataPageContext extends DataPageProps { selectedItem: any; - onSelectionChanged: (e: DataTableSelectionSingleChangeEvent) => void; + onSelectionChanged: (e: DataTableSelectionChangeEvent) => void; } const DataPageContext = React.createContext(null); @@ -223,7 +223,7 @@ export interface DataPageProps | IObservable /** * Callback for when the selection changes */ - onSelectionChange?(event: DataTableSelectionSingleChangeEvent): void; + onSelectionChange?(event: DataTableSelectionChangeEvent): void; /** * Fields to use for global filtering @@ -251,27 +251,18 @@ export interface DataPageProps | IObservable tableClassName?: string; /** PrimeReact pass-through configuration applied to the inner DataTable. */ - tablePt?: PrimeDataTableProps['pt']; + tablePt?: DataTableRootProps['pt']; /** PrimeReact pass-through options applied to the inner DataTable. */ - tablePtOptions?: PrimeDataTableProps['ptOptions']; + tablePtOptions?: DataTableRootProps['ptOptions']; /** When true, disables every base PrimeReact style on the inner DataTable. */ tableUnstyled?: boolean; /** - * Extra CSS class name forwarded to the action Menubar root. + * Extra CSS class name forwarded to the action menubar root. */ menubarClassName?: string; - - /** PrimeReact pass-through configuration applied to the action Menubar. */ - menubarPt?: MenubarProps['pt']; - - /** PrimeReact pass-through options applied to the action Menubar. */ - menubarPtOptions?: MenubarProps['ptOptions']; - - /** When true, disables every base PrimeReact style on the action Menubar. */ - menubarUnstyled?: boolean; } /** @@ -359,7 +350,7 @@ export interface DataPageProps | IObservable const DataPage = | IObservableQueryFor, TDataType extends object, TArguments extends object>(props: DataPageProps) => { const [selectedItem, setSelectedItem] = React.useState(undefined); - const selectionChanged = (e: DataTableSelectionSingleChangeEvent) => { + const selectionChanged = (e: DataTableSelectionChangeEvent) => { setSelectedItem(e.value); if (props.onSelectionChange) { props.onSelectionChange(e); diff --git a/Source/DataPage/index.ts b/Source/DataPage/index.ts index a099876..2eca460 100644 --- a/Source/DataPage/index.ts +++ b/Source/DataPage/index.ts @@ -2,3 +2,4 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. export * from './DataPage'; +export { Column, type ColumnProps } from '../DataTables/Column'; diff --git a/Source/SchemaEditor/SchemaEditor.tsx b/Source/SchemaEditor/SchemaEditor.tsx index e1d1af7..90f8eb1 100644 --- a/Source/SchemaEditor/SchemaEditor.tsx +++ b/Source/SchemaEditor/SchemaEditor.tsx @@ -3,10 +3,10 @@ import { useState, useEffect, useMemo, useCallback } from 'react'; import { Button } from 'primereact/button'; -import { DataTable } from 'primereact/datatable'; -import { Column } from 'primereact/column'; -import { Menubar } from 'primereact/menubar'; -import { Tooltip } from 'primereact/tooltip'; +import { DataTableCore } from '../DataTables/DataTableCore'; +import { Column } from '../DataTables/Column'; +import { ActionMenubar, type ActionMenuItem } from '../Common/ActionMenubar'; +import { Tooltip } from '../Common/Tooltip'; import * as faIcons from 'react-icons/fa6'; import { NameCell } from './NameCell'; import { TypeCell } from './TypeCell'; @@ -14,7 +14,6 @@ import { JsonSchema, JsonSchemaProperty } from '../types/JsonSchema'; import { TypeFormat, DEFAULT_TYPE_FORMATS } from '../types/TypeFormat'; import { validatePropertyName, buildBreadcrumbItems } from './schemaHelpers'; import css from './SchemaEditor.module.css'; -import { MenuItem } from 'primereact/menuitem'; /** * Props for {@link SchemaEditor}. @@ -347,22 +346,21 @@ export const SchemaEditor = ({ const hasValidationErrors = Object.keys(validationErrors).length > 0; - const menuItems = useMemo(() => [ + const menuItems = useMemo(() => [ ...(!isEditMode ? [{ label: 'Edit', icon: , command: canEdit ? handleEdit : undefined, className: !canEdit ? 'edit-disabled-with-reason' : undefined, - template: !canEdit && canNotEditReason ? (item: MenuItem) => ( -
- {item.icon} - {item.label} -
+ template: !canEdit && canNotEditReason ? (item: ActionMenuItem) => ( + +
+ {item.icon} + {item.label} +
+
) : undefined }] : []), ...(isEditMode ? [ @@ -392,23 +390,24 @@ export const SchemaEditor = ({ return (
-
- +
- +
{breadcrumbItems.map((item, index) => ( @@ -432,14 +431,12 @@ export const SchemaEditor = ({
- - - key={`${isEditMode}-${currentPath.join('/')}`} - value={properties} + data={properties} dataKey="id" emptyMessage="No properties defined" - rowClassName={(rowData: JsonSchemaProperty) => { + rowClassName={(rowData) => { if (!isEditMode && (rowData.type === 'object' || (rowData.type === 'array' && rowData.items?.type === 'object'))) { return css.navigableRow; } @@ -447,7 +444,7 @@ export const SchemaEditor = ({ }} onRowClick={(e) => { if (!isEditMode) { - const rowData = e.data as JsonSchemaProperty; + const rowData = e.data; if (rowData.name) { if (rowData.type === 'object') { navigateToProperty(rowData.name); @@ -491,7 +488,7 @@ export const SchemaEditor = ({ )} style={{ width: '70%' }} /> - +
); diff --git a/Source/SchemaEditor/TypeCell.tsx b/Source/SchemaEditor/TypeCell.tsx index 5893117..d092b9e 100644 --- a/Source/SchemaEditor/TypeCell.tsx +++ b/Source/SchemaEditor/TypeCell.tsx @@ -2,7 +2,8 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. import { Button } from 'primereact/button'; -import { Dropdown } from 'primereact/dropdown'; +import { Dropdown } from '../Dropdown/Dropdown'; +import { Tooltip } from '../Common/Tooltip'; import * as faIcons from 'react-icons/fa6'; import { TypeFormat } from '../types/TypeFormat'; import { JsonSchemaProperty } from '../types/JsonSchema'; @@ -93,37 +94,31 @@ export const TypeCell = ({ const itemType = rowData.items?.type || 'string'; const isNavigable = itemType === 'object'; return ( -
- Array of {itemType} - {isNavigable && ( - <> -
- - - - - )} -
+ +
+ Array of {itemType} + {isNavigable && ( + <> +
+ + + + + )} +
+ ); } else if (rowData.type === 'object') { return ( -
- Object -
- - - -
+ +
+ Object +
+ + + +
+ ); } return displayValue; @@ -131,18 +126,22 @@ export const TypeCell = ({ return (
- value={currentValue} options={allTypeOptions} + optionLabel="label" + optionValue="value" onChange={(e) => handleTypeChange(e.value, rowData.name || '')} className="flex-1" /> {rowData.type === 'array' && rowData.items && ( <> of - value={rowData.items.type || 'string'} options={allTypeOptions} + optionLabel="label" + optionValue="value" onChange={(e) => handleTypeChange(e.value, rowData.name || '', true)} className="flex-1" /> @@ -150,35 +149,25 @@ export const TypeCell = ({ )}
{rowData.type === 'array' && rowData.items?.type === 'object' && rowData.name && ( - + )} {rowData.type === 'object' && rowData.name && ( - + )} {rowData.name && ( - + )}
From a6cf322f1397a3d411a7219debb15fa18586a071 Mon Sep 17 00:00:00 2001 From: woksin Date: Thu, 16 Jul 2026 12:04:18 +0200 Subject: [PATCH 06/42] Migrate remaining wrappers to PrimeReact 11 Phase 8 of the PrimeReact 11 migration. - ObjectContentEditor: move its boolean/number/date/long-text editors onto the PrimeReact 11 compositional Checkbox and InputNumber, the renamed Textarea (was InputTextarea), and the shared DatePickerInput (was the removed primereact/calendar), and its info-icon tooltip onto the Cratis Tooltip wrapper (off the removed data-pr-tooltip directive). - Extract DatePickerInput, a wrapper-owned Date value/onChange picker that assembles the compositional DatePicker once, and refactor CalendarField to reuse it instead of duplicating the ~45-line composition. - ObjectNavigationalBar: convert its back button to a PrimeReact 11 icon button (children + iconOnly) wrapped in the Cratis Tooltip. - TimeMachine/EventsView: rebuild the Timeline on the v11 compositional Timeline (Root + Event/Separator/Marker/Connector/Content) mapping each event into the parts, replacing the removed monolithic value/content/marker props. Co-Authored-By: Claude Opus 4.8 (1M context) --- Source/CommandForm/fields/CalendarField.tsx | 75 +++-------- Source/Common/DatePickerInput.tsx | 122 ++++++++++++++++++ .../ObjectContentEditor.tsx | 64 ++++----- .../ObjectNavigationalBar.tsx | 21 +-- Source/TimeMachine/EventsView.tsx | 24 ++-- 5 files changed, 202 insertions(+), 104 deletions(-) create mode 100644 Source/Common/DatePickerInput.tsx diff --git a/Source/CommandForm/fields/CalendarField.tsx b/Source/CommandForm/fields/CalendarField.tsx index 8f5cac2..f400551 100644 --- a/Source/CommandForm/fields/CalendarField.tsx +++ b/Source/CommandForm/fields/CalendarField.tsx @@ -2,10 +2,8 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. import { asCommandFormField, WrappedFieldProps } from '@cratis/arc.react/commands'; -import { DatePicker } from 'primereact/datepicker'; -import { InputText } from 'primereact/inputtext'; -import { Button } from 'primereact/button'; -import type { DatePickerRootProps, DatePickerRootValueChangeEvent } from '@primereact/types/primitive/datepicker'; +import { DatePickerInput } from '../../Common/DatePickerInput'; +import type { DatePickerRootProps } from '@primereact/types/primitive/datepicker'; import React from 'react'; /** @@ -61,58 +59,23 @@ interface CalendarFieldComponentProps extends WrappedFieldProps { */ export const CalendarField = asCommandFormField( (props) => ( - // PrimeReact 11's DatePicker is compositional: Root owns the date model, Input is - // the text field, and the popup Calendar/Table auto-render the grid. `onBlur` rides - // the wrapping div because React blur bubbles (focusout). -
- props.onChange(e.value instanceof Date ? e.value : null)} - invalid={props.invalid} - dateFormat={props.dateFormat} - showTime={props.showTime} - hourFormat={props.hourFormat} - minDate={props.minDate} - maxDate={props.maxDate} - pt={props.pt} - ptOptions={props.ptOptions} - unstyled={props.unstyled}> - - {props.showIcon && ( - - - - )} - - - - - - - - - - - - - - - - - - - - - - - - - {props.showTime && } - - - - -
+ ), { defaultValue: null, diff --git a/Source/Common/DatePickerInput.tsx b/Source/Common/DatePickerInput.tsx new file mode 100644 index 0000000..cc0ca0b --- /dev/null +++ b/Source/Common/DatePickerInput.tsx @@ -0,0 +1,122 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import React, { type CSSProperties, type FocusEventHandler } from 'react'; +import { DatePicker } from 'primereact/datepicker'; +import { InputText } from 'primereact/inputtext'; +import { Button } from 'primereact/button'; +import type { DatePickerRootProps, DatePickerRootValueChangeEvent } from '@primereact/types/primitive/datepicker'; + +/** Props for {@link DatePickerInput}. */ +export interface DatePickerInputProps { + /** The selected date, or `null` when nothing is selected. */ + value: Date | null; + /** Invoked with the newly-selected date (or `null`). */ + onChange: (value: Date | null) => void; + /** Invoked when focus leaves the control. */ + onBlur?: FocusEventHandler; + /** Renders the control in an invalid (error) state. */ + invalid?: boolean; + /** Placeholder text shown when no date is selected. */ + placeholder?: string; + /** PrimeReact-style date format mask (e.g. `'yy-mm-dd'`). */ + dateFormat?: string; + /** When true, renders a trailing calendar icon button. */ + showIcon?: boolean; + /** When true, includes time selection alongside the date. */ + showTime?: boolean; + /** Hour format used when {@link showTime} is true. */ + hourFormat?: '12' | '24'; + /** Earliest selectable date. */ + minDate?: Date; + /** Latest selectable date. */ + maxDate?: Date; + /** Extra CSS class name combined with the default `w-full`. */ + className?: string; + /** Inline style for the wrapping element. */ + style?: CSSProperties; + /** PrimeReact pass-through configuration applied to the underlying DatePicker. */ + pt?: DatePickerRootProps['pt']; + /** PrimeReact pass-through options applied to the underlying DatePicker. */ + ptOptions?: DatePickerRootProps['ptOptions']; + /** When true, disables every base PrimeReact style on the underlying DatePicker. */ + unstyled?: boolean; +} + +/** + * A wrapper-owned date (or date-time) picker with a simple `value` / `onChange` + * (`Date | null`) surface, assembling PrimeReact 11's compositional + * `DatePicker` (Root owns the date model, Input is the text field, and the + * popup Calendar/Table auto-render the grid). Shared by {@link CalendarField} + * (the command-bound field) and other editors that need a raw date input. + * + * `onBlur` rides the wrapping `
` because React blur bubbles (focusout). + */ +export const DatePickerInput = ({ + value, + onChange, + onBlur, + invalid, + placeholder, + dateFormat, + showIcon, + showTime, + hourFormat, + minDate, + maxDate, + className, + style, + pt, + ptOptions, + unstyled, +}: DatePickerInputProps) => ( +
+ onChange(e.value instanceof Date ? e.value : null)} + invalid={invalid} + dateFormat={dateFormat} + showTime={showTime} + hourFormat={hourFormat} + minDate={minDate} + maxDate={maxDate} + pt={pt} + ptOptions={ptOptions} + unstyled={unstyled}> + + {showIcon && ( + + + + )} + + + + + + + + + + + + + + + + + + + + + + + + + {showTime && } + + + + +
+); diff --git a/Source/ObjectContentEditor/ObjectContentEditor.tsx b/Source/ObjectContentEditor/ObjectContentEditor.tsx index fe374da..3bc0e05 100644 --- a/Source/ObjectContentEditor/ObjectContentEditor.tsx +++ b/Source/ObjectContentEditor/ObjectContentEditor.tsx @@ -1,7 +1,7 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. -import { Tooltip } from 'primereact/tooltip'; +import { Tooltip } from '../Common/Tooltip'; import React, { useState, useCallback, useMemo, useEffect } from 'react'; import * as faIcons from 'react-icons/fa6'; import { ObjectNavigationalBar } from '../ObjectNavigationalBar'; @@ -9,9 +9,11 @@ import { Json, JsonSchema, JsonSchemaProperty } from '../types/JsonSchema'; import { getValueAtPath } from './objectHelpers'; import { InputText } from 'primereact/inputtext'; import { InputNumber } from 'primereact/inputnumber'; +import type { InputNumberRootValueChangeEvent } from '@primereact/types/primitive/inputnumber'; import { Checkbox } from 'primereact/checkbox'; -import { Calendar } from 'primereact/calendar'; -import { InputTextarea } from 'primereact/inputtextarea'; +import type { CheckboxRootChangeEvent } from '@primereact/types/primitive/checkbox'; +import { Textarea } from 'primereact/textarea'; +import { DatePickerInput } from '../Common/DatePickerInput'; /** * Props for {@link ObjectContentEditor}. @@ -236,18 +238,17 @@ export const ObjectContentEditor = ({ object, timestamp, schema, editMode = fals }); }; - const inputStyle = { - width: '100%', - ...(error ? { borderColor: 'var(--cratis-red-500)' } : {}) - }; - if (property.type === 'boolean') { return (
- handleChange(e.checked ?? false)} - /> + onCheckedChange={(e: CheckboxRootChangeEvent) => handleChange(e.checked ?? false)} + invalid={!!error}> + + + + {error && {error}}
); @@ -256,13 +257,13 @@ export const ObjectContentEditor = ({ object, timestamp, schema, editMode = fals if (property.type === 'number' || property.type === 'integer') { return (
- handleChange(e.value ?? null)} - mode="decimal" + onValueChange={(e: InputNumberRootValueChangeEvent) => handleChange(e.value ?? null)} useGrouping={false} - style={inputStyle} - /> + invalid={!!error}> + + {error && {error}}
); @@ -272,12 +273,12 @@ export const ObjectContentEditor = ({ object, timestamp, schema, editMode = fals const dateValue = value ? new Date(value as string) : null; return (
- handleChange(e.value instanceof Date ? e.value.toISOString() : null)} + onChange={(date) => handleChange(date ? date.toISOString() : null)} showTime showIcon - style={inputStyle} + invalid={!!error} /> {error && {error}}
@@ -288,11 +289,11 @@ export const ObjectContentEditor = ({ object, timestamp, schema, editMode = fals const dateValue = value ? new Date(value as string) : null; return (
- handleChange(e.value instanceof Date ? e.value.toISOString().split('T')[0] : null)} + onChange={(date) => handleChange(date ? date.toISOString().split('T')[0] : null)} showIcon - style={inputStyle} + invalid={!!error} /> {error && {error}}
@@ -320,11 +321,12 @@ export const ObjectContentEditor = ({ object, timestamp, schema, editMode = fals if (isLongText) { return (
- handleChange(e.target.value)} rows={3} - style={inputStyle} + invalid={!!error} + className="w-full" /> {error && {error}}
@@ -336,7 +338,8 @@ export const ObjectContentEditor = ({ object, timestamp, schema, editMode = fals handleChange(e.target.value)} - style={inputStyle} + invalid={!!error} + className="w-full" /> {error && {error}}
@@ -442,11 +445,11 @@ export const ObjectContentEditor = ({ object, timestamp, schema, editMode = fals {propertyName} {description && ( - + + + )} @@ -466,7 +469,6 @@ export const ObjectContentEditor = ({ object, timestamp, schema, editMode = fals return (
-
- +
{breadcrumbItems.map((item, index) => ( diff --git a/Source/TimeMachine/EventsView.tsx b/Source/TimeMachine/EventsView.tsx index 48dbe8c..1533584 100644 --- a/Source/TimeMachine/EventsView.tsx +++ b/Source/TimeMachine/EventsView.tsx @@ -2,7 +2,8 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. import React, { useEffect, useRef, useState } from 'react'; -import { Timeline, type TimelineProps } from 'primereact/timeline'; +import { Timeline } from 'primereact/timeline'; +import type { TimelineRootProps } from '@primereact/types/primitive/timeline'; import type { Event } from './types'; import { Properties } from './Properties'; import './EventsView.css'; @@ -18,10 +19,10 @@ interface EventsViewProps { className?: string; /** PrimeReact pass-through configuration applied to the underlying Timeline. */ - pt?: TimelineProps['pt']; + pt?: TimelineRootProps['pt']; /** PrimeReact pass-through options applied to the underlying Timeline. */ - ptOptions?: TimelineProps['ptOptions']; + ptOptions?: TimelineRootProps['ptOptions']; /** When true, disables every base PrimeReact style on the underlying Timeline. */ unstyled?: boolean; @@ -122,16 +123,23 @@ export const EventsView: React.FC = ({ events, className, pt, p
)} - + > + {displayEvents.map((event, index) => ( + + + {customMarker()} + {index < displayEvents.length - 1 && } + + {customContent(event, index)} + + ))} + {canScrollDown && (
diff --git a/.ai/rules/components.md b/.ai/rules/components.md index 135ba4f..79139a8 100644 --- a/.ai/rules/components.md +++ b/.ai/rules/components.md @@ -33,8 +33,7 @@ Use `Dropdown` from `@cratis/components/Dropdown` (not raw `primereact/dropdown` Columns and toolbar actions are compositional children: ```tsx -import { DataPage, MenuItem } from '@cratis/components/DataPage'; -import { Column } from 'primereact/column'; +import { DataPage, MenuItem, Column } from '@cratis/components/DataPage'; diff --git a/.ai/skills/cratis-react-page/SKILL.md b/.ai/skills/cratis-react-page/SKILL.md index aff9163..c26268d 100644 --- a/.ai/skills/cratis-react-page/SKILL.md +++ b/.ai/skills/cratis-react-page/SKILL.md @@ -14,7 +14,7 @@ Import `DataPage` (and its `Column`/`MenuItem` helpers) from the **subpath**, no ```tsx import { DataPage, MenuItem } from '@cratis/components/DataPage'; -import { Column } from 'primereact/column'; +import { Column } from '@cratis/components/DataPage'; import { CommandDialog } from '@cratis/components/CommandDialog'; import { useDialog, DialogProps } from '@cratis/arc.react/dialogs'; ``` @@ -27,7 +27,7 @@ import { useDialog, DialogProps } from '@cratis/arc.react/dialogs'; ```tsx import { DataPage } from '@cratis/components/DataPage'; -import { Column } from 'primereact/column'; +import { Column } from '@cratis/components/DataPage'; import { AllAccounts } from './AllAccounts'; export const AccountsPage = () => ( @@ -68,7 +68,7 @@ export const CreateAccountDialog = ({ closeDialog }: DialogProps) => ( ```tsx import { DataPage, MenuItem } from '@cratis/components/DataPage'; -import { Column } from 'primereact/column'; +import { Column } from '@cratis/components/DataPage'; import { useDialog } from '@cratis/arc.react/dialogs'; import { CreateAccountDialog } from './CreateAccountDialog'; diff --git a/.ai/skills/new-vertical-slice/references/PATTERNS.md b/.ai/skills/new-vertical-slice/references/PATTERNS.md index b8b68bf..c6e132e 100644 --- a/.ai/skills/new-vertical-slice/references/PATTERNS.md +++ b/.ai/skills/new-vertical-slice/references/PATTERNS.md @@ -244,7 +244,7 @@ public record StockDecreased(ISBN Isbn, BookStock StockBeforeDecrease); // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. -import { Column } from 'primereact/column'; +import { Column } from '@cratis/components/DataPage'; import { DataTable } from 'primereact/datatable'; import { AllProjects } from './AllProjects'; @@ -318,8 +318,7 @@ export const AddProject = ({ closeDialog }: DialogProps) => { // Licensed under the MIT license. See LICENSE file in the project root for full license information. import { DialogResult, useDialog } from '@cratis/arc.react/dialogs'; -import { Menubar } from 'primereact/menubar'; -import { MenuItem } from 'primereact/menuitem'; +import { Button } from 'primereact/button'; import * as mdIcons from 'react-icons/md'; import { Page } from '@cratis/components/Common'; import { AddProject } from './Registration/AddProject'; @@ -328,17 +327,15 @@ import { Listing } from './Listing/Listing'; export const Projects = () => { const [AddProjectDialog, showAddProjectDialog] = useDialog(AddProject); - const menuItems: MenuItem[] = [ - { - label: 'Add Project', - icon: mdIcons.MdAdd, - command: async () => { await showAddProjectDialog(); } - } - ]; - + // PrimeReact 11 removed the standalone Menubar; for a query-backed list + // page prefer `DataPage` + ``, or compose `Button`s + // (content is children in v11) for a custom toolbar. return ( - + diff --git a/.ai/skills/stepper-command-dialog/SKILL.md b/.ai/skills/stepper-command-dialog/SKILL.md index 5cca013..3c8f1be 100644 --- a/.ai/skills/stepper-command-dialog/SKILL.md +++ b/.ai/skills/stepper-command-dialog/SKILL.md @@ -36,7 +36,7 @@ Run a Release `dotnet build` to generate the `CreateProject` TypeScript proxy be ```tsx import { StepperCommandDialog } from '@cratis/components/CommandDialog'; -import { StepperPanel } from 'primereact/stepperpanel'; +import { StepperPanel } from '@cratis/components/CommandDialog'; import { InputTextField, TextAreaField, NumberField } from '@cratis/components/CommandForm/fields'; import { DialogResult, useDialogContext } from '@cratis/arc.react/dialogs'; import { CreateProject } from '../api/Projects/CreateProject'; diff --git a/Source/README.md b/Source/README.md index 6cec797..6d8156f 100644 --- a/Source/README.md +++ b/Source/README.md @@ -142,36 +142,43 @@ export const App = () => ( the package plus the `--cratis-*` CSS variable token layer that every internal component reads from. (Use `@cratis/components/tokens` instead if you're bringing your own Tailwind.) -- `CratisComponentsProvider` is a thin wrapper over PrimeReact's - `PrimeReactProvider` so Cratis has one place to layer in defaults. Drop in - raw `PrimeReactProvider` if you'd rather. +- `CratisComponentsProvider` is a thin wrapper over `@primereact/core`'s + `PrimeReactProvider` so Cratis has one place to layer in defaults (including + the optional `theme={{ preset }}` styled layer). Drop in the raw + `PrimeReactProvider` from `@primereact/core` if you'd rather. The three setups below differ only in **what else** you load on top of this setup. --- -### Use a PrimeReact theme +### Use a styled preset -Load any PrimeReact theme stylesheet alongside Cratis Components. PrimeReact's -own widgets paint themselves from the theme, and the `--cratis-*` tokens cascade -to the matching theme variables so Cratis-scoped surfaces follow along. +PrimeReact 11 dropped the v10 `primereact/resources/themes/*/theme.css` +stylesheets in favor of the token-based `@primeuix/themes` layer. Apply a +preset by passing `theme={{ preset }}` to `CratisComponentsProvider` — no theme +CSS import. PrimeReact's own widgets paint themselves from the preset, and the +`--cratis-*` tokens cascade to the matching variables so Cratis-scoped surfaces +follow along. ```tsx -// 1. Theme first, then Cratis styles so any --cratis-* override wins. -import 'primereact/resources/themes/lara-dark-blue/theme.css'; +import Aura from '@primeuix/themes/aura'; // or lara / nora / material import 'primeicons/primeicons.css'; import '@cratis/components/styles'; import { CratisComponentsProvider } from '@cratis/components'; export const App = () => ( - + ); ``` +Omit `theme` entirely to stay unstyled-first — ship only structure plus the +`--cratis-*` tokens and bring your own visuals (see the pass-through / `pt` +options below). + #### Override a single component with CSS Plain CSS works fine on top of the theme. Target either PrimeReact's class @@ -255,11 +262,10 @@ independently if you want Cratis surfaces to differ from PrimeReact widgets. ``` ```tsx -// 1. PrimeReact theme provides the structure. -import 'primereact/resources/themes/lara-dark-blue/theme.css'; +// 1. A styled preset provides the structure (apply it via the provider — see above). import 'primeicons/primeicons.css'; import '@cratis/components/styles'; -// 2. Your palette overrides — must come after the theme so they win. +// 2. Your palette overrides — must come after the styles so they win. import './palette.override.css'; ``` @@ -290,7 +296,6 @@ Tailwind handles cascade and dark mode: ```css /* app.css */ @import "tailwindcss"; -@import "primereact/resources/themes/lara-dark-blue/theme.css"; @import "@cratis/components/styles"; @layer base { diff --git a/Source/package.json b/Source/package.json index 21ddc5f..a3babef 100644 --- a/Source/package.json +++ b/Source/package.json @@ -1,6 +1,6 @@ { "name": "@cratis/components", - "version": "0.1.8", + "version": "0.2.0", "description": "", "author": "Cratis", "license": "MIT", From 4db9595023caff7c6bcc014c7438b2cdc00c0e37 Mon Sep 17 00:00:00 2001 From: woksin Date: Thu, 16 Jul 2026 12:16:46 +0200 Subject: [PATCH 09/42] Remap the Storybook pt preset to PrimeReact 11 slots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 9 of the PrimeReact 11 migration. Update the Storybook-only Tailwind pt preset to PrimeReact 11 component keys: rename dropdown → select (with the v11 value/dropdown/popup/option slots), inputtextarea → textarea, and drop the menubar preset (Menubar was removed; the Cratis action bar is a Button toolbar styled through the button slot). Co-Authored-By: Claude Opus 4.8 (1M context) --- Source/.storybook/pt-preset.ts | 29 +++++++++++++---------------- 1 file changed, 13 insertions(+), 16 deletions(-) diff --git a/Source/.storybook/pt-preset.ts b/Source/.storybook/pt-preset.ts index 56ee1b5..e0c5836 100644 --- a/Source/.storybook/pt-preset.ts +++ b/Source/.storybook/pt-preset.ts @@ -8,7 +8,11 @@ * built entirely from Tailwind utility classes. * * This file is Storybook-only and not part of the published package. Treat it - * as a starting point you can fork into your own app. + * as a PrimeReact 11 starting point you can fork into your own app — the + * per-component keys match PrimeReact 11 (`select`, `textarea`, …) and the + * leaf slots below cover the common structure; a component's deeper headless + * slots (e.g. the compositional Select/DataTable parts) can be extended as + * needed, and any slot a component doesn't expose is simply ignored. */ const surface = 'bg-slate-900 text-slate-50'; @@ -44,7 +48,7 @@ export const tailwindPtPreset = { }, }, - inputtextarea: { + textarea: { root: { className: [ 'w-full px-3 py-2 rounded-md', @@ -70,7 +74,7 @@ export const tailwindPtPreset = { }, }, - dropdown: { + select: { root: { className: [ 'w-full inline-flex items-center justify-between gap-2', @@ -80,15 +84,15 @@ export const tailwindPtPreset = { focusRing, ].join(' '), }, - input: { className: 'flex-1 truncate text-left' }, - trigger: { className: 'shrink-0 text-slate-400' }, - panel: { + value: { className: 'flex-1 truncate text-left' }, + dropdown: { className: 'shrink-0 text-slate-400' }, + popup: { className: [ 'mt-1 rounded-md shadow-xl overflow-hidden', surface, border, ].join(' '), }, - item: { + option: { className: 'px-3 py-2 cursor-pointer hover:bg-slate-800', }, }, @@ -165,13 +169,6 @@ export const tailwindPtPreset = { }, }, - menubar: { - root: { - className: 'flex items-center gap-1 px-3 py-2 bg-slate-800 border-b border-slate-700', - }, - menuitem: { className: 'rounded' }, - action: { - className: 'inline-flex items-center gap-2 px-3 py-1.5 rounded hover:bg-slate-700 cursor-pointer', - }, - }, + // PrimeReact 11 removed Menubar; the Cratis action bar is a Button toolbar + // styled through the `button` slot above, so no menubar preset is needed. } as const; From 1db96cafe6d7e334076a52f2eb045a7fcbdf8e73 Mon Sep 17 00:00:00 2001 From: woksin Date: Thu, 16 Jul 2026 12:25:21 +0200 Subject: [PATCH 10/42] Render a selection radio for selectionMode data-table columns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `` column rendered an empty cell after the headless rebuild — row-click selection worked, but the per-row radio the v10 DataTable drew was gone. Render a radio that reflects the selected row so the selection column keeps its familiar affordance. Co-Authored-By: Claude Opus 4.8 (1M context) --- Source/DataTables/DataTableCore.tsx | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/Source/DataTables/DataTableCore.tsx b/Source/DataTables/DataTableCore.tsx index c087129..d7844ae 100644 --- a/Source/DataTables/DataTableCore.tsx +++ b/Source/DataTables/DataTableCore.tsx @@ -188,7 +188,16 @@ export const DataTableCore = ({ key={columnIndex} style={{ ...column.props.style, ...column.props.bodyStyle }} className={column.props.bodyClassName ?? column.props.className}> - {renderCellContent(column.props, item)} + {column.props.selectionMode ? ( + + ) : ( + renderCellContent(column.props, item) + )} ))} From 088261ebca68c0522adb5567671608d7839c186f Mon Sep 17 00:00:00 2001 From: woksin Date: Thu, 16 Jul 2026 12:36:11 +0200 Subject: [PATCH 11/42] Restore Stepper orientation, headerPosition, start, and end PrimeReact 11's compositional Stepper dropped the v10 orientation / start / end / headerPosition props, and the earlier migration removed them from the Cratis wrapper. Reimplement them over the compositional parts so the wizard keeps full capability parity (and works regardless of theme): - orientation="vertical" lays the step list out as a stacked column beside the panels (CSS keyed on the wrapper's orientation class); horizontal stays the default. - headerPosition="bottom" renders the panels above the step-header row. - start / end render arbitrary content before / after the stepper (e.g. a logo or title). Threaded through CommandStepper and StepperCommandDialog, plus a Vertical story. Co-Authored-By: Claude Opus 4.8 (1M context) --- Source/CommandDialog/CommandStepper.css | 39 ++++++++ .../CommandDialog/CommandStepper.stories.tsx | 52 +++++++++++ Source/CommandDialog/CommandStepper.tsx | 91 ++++++++++++++----- Source/CommandDialog/StepperCommandDialog.tsx | 16 ++++ 4 files changed, 175 insertions(+), 23 deletions(-) diff --git a/Source/CommandDialog/CommandStepper.css b/Source/CommandDialog/CommandStepper.css index b3e2f12..1176d56 100644 --- a/Source/CommandDialog/CommandStepper.css +++ b/Source/CommandDialog/CommandStepper.css @@ -24,3 +24,42 @@ .cratis-command-stepper [data-scope="stepper"][data-part="step"]:not([data-active]) [data-part="header"] { opacity: 0.5; } + +/* Horizontal (default): the step list flows left-to-right. */ +.cratis-command-stepper--horizontal [data-scope="stepper"][data-part="list"] { + display: flex; + flex-direction: row; + align-items: center; +} + +/* Vertical: the step list is a stacked column beside the panels. */ +.cratis-command-stepper--vertical [data-scope="stepper"][data-part="root"] { + display: flex; + flex-direction: row; + align-items: flex-start; + gap: 1.5rem; +} + +.cratis-command-stepper--vertical [data-scope="stepper"][data-part="list"] { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.25rem; +} + +.cratis-command-stepper--vertical [data-scope="stepper"][data-part="step"] { + display: flex; + flex-direction: column; + align-items: flex-start; +} + +.cratis-command-stepper--vertical [data-scope="stepper"][data-part="separator"] { + width: 2px; + min-height: 1.25rem; + margin-left: 0.9rem; +} + +.cratis-command-stepper--vertical [data-scope="stepper"][data-part="panels"] { + flex: 1; + min-width: 0; +} diff --git a/Source/CommandDialog/CommandStepper.stories.tsx b/Source/CommandDialog/CommandStepper.stories.tsx index a3ca7f2..a7d66af 100644 --- a/Source/CommandDialog/CommandStepper.stories.tsx +++ b/Source/CommandDialog/CommandStepper.stories.tsx @@ -244,6 +244,58 @@ export const InDialogFrameWithCenteredHeader: Story = { }, }; +export const Vertical: Story = { + render: () => { + const [result, setResult] = useState(''); + + return ( +
+ + command={CreateProjectCommand} + autoServerValidate={false} + validateOn="change" + orientation="vertical" + start={

Create Project

} + onSuccess={async () => setResult('Command submitted successfully')} + > + + + value={c => c.name} + title="Project Name" + placeholder="Enter project name (min 2 chars)" + /> + + value={c => c.email} + title="Contact Email" + placeholder="Enter contact email" + type="email" + /> + + + + value={c => c.description} + title="Description" + placeholder="Describe the project (min 10 chars)" + rows={4} + /> + + value={c => c.budget} + title="Budget" + placeholder="Enter budget (must be > 0)" + /> + + + + {result && ( +
+ {result} +
+ )} +
+ ); + }, +}; + export const WithValidationIndicators: Story = { render: () => { const [result, setResult] = useState(''); diff --git a/Source/CommandDialog/CommandStepper.tsx b/Source/CommandDialog/CommandStepper.tsx index 4ea2ba1..3df2def 100644 --- a/Source/CommandDialog/CommandStepper.tsx +++ b/Source/CommandDialog/CommandStepper.tsx @@ -26,14 +26,19 @@ export interface StepperChangeEvent { index: number; } +/** Orientation of a {@link CommandStepper} / {@link StepperCommandDialog}. */ +export type StepperOrientation = 'horizontal' | 'vertical'; + +/** Where the step headers sit relative to the panels. */ +export type StepperHeaderPosition = 'top' | 'bottom'; + /** * Stepper-specific customization surface exposed by {@link CommandStepper} and * {@link StepperCommandDialog}. This is a Cratis-owned type — it no longer * leaks PrimeReact's `StepperProps` — so PrimeReact 11's compositional Stepper - * can be rebuilt underneath without changing the public API. - * - * The PrimeReact 10 slots `orientation`, `headerPosition`, `start`, and `end` - * have no PrimeReact 11 equivalent and were removed. + * can be rebuilt underneath without changing the public API. PrimeReact 11's + * Stepper has no built-in `orientation` / `start` / `end` / `headerPosition`, + * so the wrapper re-implements them over the compositional parts. */ export interface StepperCustomizationProps { /** @@ -43,6 +48,14 @@ export interface StepperCustomizationProps { * their headers. Defaults to `true`. */ linear?: boolean; + /** Lays the steps out horizontally (default) or stacked vertically. */ + orientation?: StepperOrientation; + /** Places the step-header row above (default) or below the panels. */ + headerPosition?: StepperHeaderPosition; + /** Content rendered before the stepper (e.g. a logo or title). */ + start?: React.ReactNode; + /** Content rendered after the stepper. */ + end?: React.ReactNode; /** Invoked when the active step changes (via navigation or a header click). */ onChangeStep?: (event: StepperChangeEvent) => void; /** PrimeReact pass-through configuration for the underlying Stepper parts. */ @@ -178,6 +191,10 @@ export const CommandStepperContent = ({ isSubmitDisabled = false, onSubmit, linear = true, + orientation = 'horizontal', + headerPosition = 'top', + start, + end, onChangeStep, pt, ptOptions, @@ -251,34 +268,46 @@ export const CommandStepperContent = ({ onActiveStepChange?.(Math.min(stepCount - 1, activeStep + 1)); }; + const stepperList = ( + + {panels.map((panel, index) => ( + + + {index + 1} + {panel.props.header} + + {index < stepCount - 1 && } + + ))} + + ); + + const stepperPanels = ( + + {panels.map((panel, index) => ( + + {processChildren(panel.props.children)} + + ))} + + ); + return ( -
+
+ {start} - - {panels.map((panel, index) => ( - - - {index + 1} - {panel.props.header} - - {index < stepCount - 1 && } - - ))} - - - {panels.map((panel, index) => ( - - {processChildren(panel.props.children)} - - ))} - + {headerPosition === 'bottom' + ? (<>{stepperPanels}{stepperList}) + : (<>{stepperList}{stepperPanels})} + {end} {showNavigation && (
@@ -340,6 +369,10 @@ const CommandStepperWrapper = ({ okLabel, isBusy, linear, + orientation, + headerPosition, + start, + end, onChangeStep, pt, ptOptions, @@ -401,6 +434,10 @@ const CommandStepperWrapper = ({ isSubmitDisabled={!isCommandFormValid} onSubmit={handleSubmit} linear={linear} + orientation={orientation} + headerPosition={headerPosition} + start={start} + end={end} onChangeStep={onChangeStep} pt={pt} ptOptions={ptOptions} @@ -483,6 +520,10 @@ export const CommandStepper = Date: Thu, 16 Jul 2026 12:48:46 +0200 Subject: [PATCH 12/42] Restore DataTable per-column filter menus and add global search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PrimeReact 11's headless DataTable manages filter state and applies the actual row filtering (client-side, like sorting), but renders none of the v10 filterDisplay="menu" chrome. Rebuild that chrome and add a search box: - Add ColumnFilterMenu: a filter icon per filterable column header that opens a popover (primereact/popover) with a match-mode selector, a typed value input (text / number / date / boolean), and Clear / Apply — wired to the headless DataTable.Filter render-prop, with the popover's open state bound to the filter's overlay state so the draft re-seeds correctly. - Column gains filter / filterField / filterPlaceholder / dataType / showFilterMatchModes; DataTableCore controls the filters state, seeds it from defaultFilters, and renders a filter menu for any column with `filter`. - Add an optional global search box (shown when globalFilterFields is set) that filters across those fields — a capability the v10 table did not surface. - Wire defaultFilters through both query tables; demonstrate in the DataTableForQuery and DataPage stories. Restores full v10 filtering parity and then some. Co-Authored-By: Claude Opus 4.8 (1M context) --- Source/DataPage/DataPage.stories.tsx | 6 +- Source/DataTables/Column.tsx | 11 ++ Source/DataTables/ColumnFilterMenu.css | 41 +++++ Source/DataTables/ColumnFilterMenu.tsx | 140 ++++++++++++++++++ Source/DataTables/DataTableCore.css | 13 ++ Source/DataTables/DataTableCore.tsx | 72 +++++++-- .../DataTableForObservableQuery.tsx | 1 + .../DataTables/DataTableForQuery.stories.tsx | 5 +- Source/DataTables/DataTableForQuery.tsx | 1 + Source/DataTables/index.ts | 1 + 10 files changed, 270 insertions(+), 21 deletions(-) create mode 100644 Source/DataTables/ColumnFilterMenu.css create mode 100644 Source/DataTables/ColumnFilterMenu.tsx create mode 100644 Source/DataTables/DataTableCore.css diff --git a/Source/DataPage/DataPage.stories.tsx b/Source/DataPage/DataPage.stories.tsx index cea7380..d3c7c58 100644 --- a/Source/DataPage/DataPage.stories.tsx +++ b/Source/DataPage/DataPage.stories.tsx @@ -168,9 +168,9 @@ export const WithClientFiltering: Story = { > - - - + + +
diff --git a/Source/DataTables/Column.tsx b/Source/DataTables/Column.tsx index fd44e48..5eaa725 100644 --- a/Source/DataTables/Column.tsx +++ b/Source/DataTables/Column.tsx @@ -2,6 +2,7 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. import React from 'react'; +import type { ColumnFilterDataType } from './ColumnFilterMenu'; /** * Props for {@link Column}. @@ -17,6 +18,16 @@ export interface ColumnProps { body?: (rowData: TData) => React.ReactNode; /** When true, the column header becomes a sort control. */ sortable?: boolean; + /** When true, the column header gains a filter-menu affordance. */ + filter?: boolean; + /** The field the filter applies to, when it differs from {@link field}. */ + filterField?: string; + /** Placeholder for the filter value input. */ + filterPlaceholder?: string; + /** The value kind the filter edits (drives match modes + input). Defaults to `'text'`. */ + dataType?: ColumnFilterDataType; + /** Whether the filter menu shows the match-mode selector. Defaults to `true`. */ + showFilterMatchModes?: boolean; /** * Renders a selection control column (a radio for `single`, a checkbox for * `multiple`) instead of a data column. diff --git a/Source/DataTables/ColumnFilterMenu.css b/Source/DataTables/ColumnFilterMenu.css new file mode 100644 index 0000000..cef9261 --- /dev/null +++ b/Source/DataTables/ColumnFilterMenu.css @@ -0,0 +1,41 @@ +/* Copyright (c) Cratis. All rights reserved. */ +/* Licensed under the MIT license. See LICENSE file in the project root for full license information. */ + +.cratis-filter-trigger { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.25rem; + border: none; + background: transparent; + color: var(--cratis-text-color-secondary); + cursor: pointer; + border-radius: 0.25rem; +} + +.cratis-filter-trigger:hover { + background: var(--cratis-surface-hover); + color: var(--cratis-text-color); +} + +.cratis-filter-trigger--active { + color: var(--cratis-primary-color); +} + +.cratis-filter-menu { + display: flex; + flex-direction: column; + gap: 0.5rem; + min-width: 14rem; + padding: 0.5rem; + background: var(--cratis-surface-overlay); + border: 1px solid var(--cratis-surface-border); + border-radius: 0.375rem; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35); +} + +.cratis-filter-menu-actions { + display: flex; + justify-content: space-between; + gap: 0.5rem; +} diff --git a/Source/DataTables/ColumnFilterMenu.tsx b/Source/DataTables/ColumnFilterMenu.tsx new file mode 100644 index 0000000..17ad387 --- /dev/null +++ b/Source/DataTables/ColumnFilterMenu.tsx @@ -0,0 +1,140 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import React, { type SyntheticEvent } from 'react'; +import { DataTable } from 'primereact/datatable'; +import { Popover } from 'primereact/popover'; +import { InputText } from 'primereact/inputtext'; +import { InputNumber } from 'primereact/inputnumber'; +import type { InputNumberRootValueChangeEvent } from '@primereact/types/primitive/inputnumber'; +import type { DataTableFilterExposes } from '@primereact/types/primitive/datatable'; +import { Button } from 'primereact/button'; +import { Dropdown } from '../Dropdown/Dropdown'; +import { DatePickerInput } from '../Common/DatePickerInput'; +import './ColumnFilterMenu.css'; + +/** The value kind a {@link ColumnFilterMenu} edits, which drives the match modes and the input control. */ +export type ColumnFilterDataType = 'text' | 'numeric' | 'date' | 'boolean'; + +/** Props for {@link ColumnFilterMenu}. */ +export interface ColumnFilterMenuProps { + /** The row field this menu filters. */ + field: string; + /** The value kind — selects the match modes and the input control. Defaults to `'text'`. */ + dataType?: ColumnFilterDataType; + /** Placeholder for the value input. */ + placeholder?: string; + /** Whether to show the match-mode selector. Defaults to `true`. */ + showMatchModes?: boolean; +} + +const BOOLEAN_OPTIONS = [ + { label: 'True', value: true }, + { label: 'False', value: false }, +]; + +/** + * The filter affordance for a single data-table column: a filter icon in the + * header that opens a popover menu with a match-mode selector, a value input + * (text / number / date / boolean), and Clear / Apply actions. Restores the + * PrimeReact 10 `filterDisplay="menu"` experience on PrimeReact 11's headless + * `DataTable.Filter` state machine — which manages the filter draft and the + * actual client-side row filtering, but renders none of the chrome — portaled + * through `primereact/popover`. + * + * Rendered by {@link DataTableCore} inside a `THeadCell` for any `` + * with `filter` set. Must live inside a `DataTable.Root` subtree. + */ +export const ColumnFilterMenu = ({ field, dataType = 'text', placeholder, showMatchModes = true }: ColumnFilterMenuProps) => ( + + {(filter: DataTableFilterExposes) => { + const commit = (value: unknown, event?: SyntheticEvent) => + filter.onChange(event ?? ({} as SyntheticEvent), value, filter.matchMode); + + const valueInput = (() => { + switch (dataType) { + case 'numeric': + return ( + commit(e.value)}> + + + ); + case 'date': + return ( + commit(date)} + showIcon + /> + ); + case 'boolean': + return ( + commit(e.value)} + /> + ); + default: + return ( + commit(e.target.value, e)} + /> + ); + } + })(); + + return ( + (event.value ? filter.onShowOverlay() : filter.onHideOverlay())}> + + + + + + + +
+ {showMatchModes && ( + filter.onChange( + (e.originalEvent as SyntheticEvent) ?? ({} as SyntheticEvent), + filter.value, + e.value as string)} + /> + )} + {valueInput} +
+ + +
+
+
+
+
+
+
+ ); + }} +
+); diff --git a/Source/DataTables/DataTableCore.css b/Source/DataTables/DataTableCore.css new file mode 100644 index 0000000..9945ec1 --- /dev/null +++ b/Source/DataTables/DataTableCore.css @@ -0,0 +1,13 @@ +/* Copyright (c) Cratis. All rights reserved. */ +/* Licensed under the MIT license. See LICENSE file in the project root for full license information. */ + +.cratis-datatable-header-cell { + display: flex; + align-items: center; + gap: 0.25rem; +} + +.cratis-datatable-search { + padding: 0.5rem; + border-bottom: 1px solid var(--cratis-surface-border); +} diff --git a/Source/DataTables/DataTableCore.tsx b/Source/DataTables/DataTableCore.tsx index d7844ae..f55ba2e 100644 --- a/Source/DataTables/DataTableCore.tsx +++ b/Source/DataTables/DataTableCore.tsx @@ -1,12 +1,16 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. -import React, { useMemo, type CSSProperties, type ReactNode } from 'react'; +import React, { useMemo, useState, type CSSProperties, type ReactNode } from 'react'; import { DataTable as PrimeDataTable } from 'primereact/datatable'; +import { InputText } from 'primereact/inputtext'; import type { DataTableRootProps } from '@primereact/types/primitive/datatable'; -import type { SelectionKeys, UseDataTableSelectionEvent, UseDataTableRowMouseEvent } from '@primereact/types/headless/datatable'; +import type { SelectionKeys, UseDataTableSelectionEvent, UseDataTableRowMouseEvent, UseDataTableFilterEvent } from '@primereact/types/headless/datatable'; import type { ColumnProps } from './Column'; +import { ColumnFilterMenu } from './ColumnFilterMenu'; import type { DataTableSelectionChangeEvent } from './DataTableSelectionChangeEvent'; +import type { DataTableFilterMeta } from './DataTableFilterMeta'; +import './DataTableCore.css'; /* eslint-disable @typescript-eslint/no-explicit-any */ @@ -46,10 +50,14 @@ export interface DataTableCoreProps { onRowClick?: (event: DataTableRowClickEvent) => void; /** Computes an extra class name for each row. */ rowClassName?: (rowData: TData) => string; - /** A global filter term applied across {@link globalFilterFields}. */ - globalFilter?: string | null; - /** The fields the {@link globalFilter} term is matched against. */ + /** The fields the global search term is matched against. When set, a search box is shown above the table. */ globalFilterFields?: string[]; + /** Placeholder for the global search box. */ + globalSearchPlaceholder?: string; + /** Initial per-column filter state. */ + defaultFilters?: DataTableFilterMeta; + /** Invoked whenever the per-column filter state changes. */ + onFilter?: (filters: DataTableFilterMeta) => void; /** Renders the table body in a scroll region of {@link scrollHeight}. */ scrollable?: boolean; /** The height of the scroll region when {@link scrollable} is set. */ @@ -106,8 +114,10 @@ export const DataTableCore = ({ onSelectionChange, onRowClick, rowClassName, - globalFilter, globalFilterFields, + globalSearchPlaceholder = 'Search…', + defaultFilters, + onFilter, scrollable, scrollHeight, className, @@ -117,6 +127,14 @@ export const DataTableCore = ({ unstyled, }: DataTableCoreProps) => { const columns = useColumns(children); + const [filters, setFilters] = useState(defaultFilters ?? {}); + const [globalFilter, setGlobalFilter] = useState(''); + const showGlobalSearch = !!globalFilterFields && globalFilterFields.length > 0; + + const handleFilter = (event: UseDataTableFilterEvent) => { + setFilters(event.filters); + onFilter?.(event.filters); + }; const keyOf = (row: TData): string | undefined => dataKey ? String((row as Record)[dataKey]) : undefined; @@ -149,7 +167,9 @@ export const DataTableCore = ({ selectionKeys={selectionKeys} onSelectionChange={onSelectionChange ? handleSelectionChange : undefined} onRowClick={handleRowClick} - globalFilter={globalFilter} + filters={filters} + onFilter={handleFilter} + globalFilter={globalFilter || null} globalFilterFields={globalFilterFields} scrollable={scrollable} scrollHeight={scrollHeight} @@ -158,6 +178,16 @@ export const DataTableCore = ({ pt={pt} ptOptions={ptOptions} unstyled={unstyled}> + {showGlobalSearch && ( +
+ setGlobalFilter(event.target.value)} + /> +
+ )} @@ -167,15 +197,25 @@ export const DataTableCore = ({ key={index} style={column.props.headerStyle ?? column.props.style} className={column.props.headerClassName}> - {column.props.sortable && column.props.field ? ( - - {column.props.header} - - - - ) : ( - column.props.header - )} +
+ {column.props.sortable && column.props.field ? ( + + {column.props.header} + + + + ) : ( + {column.props.header} + )} + {column.props.filter && (column.props.filterField ?? column.props.field) && ( + + )} +
))} diff --git a/Source/DataTables/DataTableForObservableQuery.tsx b/Source/DataTables/DataTableForObservableQuery.tsx index 5261975..5586c45 100644 --- a/Source/DataTables/DataTableForObservableQuery.tsx +++ b/Source/DataTables/DataTableForObservableQuery.tsx @@ -186,6 +186,7 @@ export const DataTableForObservableQuery = - - + + Date: Thu, 16 Jul 2026 12:50:03 +0200 Subject: [PATCH 13/42] Add a range report to the table paginator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Show "X–Y of Z" in the paginator when the total record count and page size are available, so users see where they are in the full result set — a detail the minimal first/prev/next/last paginator lacked. Both query tables pass it through from the Arc paging result. Co-Authored-By: Claude Opus 4.8 (1M context) --- Source/DataTables/DataTableForObservableQuery.tsx | 2 ++ Source/DataTables/DataTableForQuery.tsx | 2 ++ Source/DataTables/TablePaginator.css | 7 +++++++ Source/DataTables/TablePaginator.tsx | 10 +++++++++- 4 files changed, 20 insertions(+), 1 deletion(-) diff --git a/Source/DataTables/DataTableForObservableQuery.tsx b/Source/DataTables/DataTableForObservableQuery.tsx index 5586c45..1f4c57b 100644 --- a/Source/DataTables/DataTableForObservableQuery.tsx +++ b/Source/DataTables/DataTableForObservableQuery.tsx @@ -204,6 +204,8 @@ export const DataTableForObservableQuery =
diff --git a/Source/DataTables/DataTableForQuery.tsx b/Source/DataTables/DataTableForQuery.tsx index 7238a0f..ec896c7 100644 --- a/Source/DataTables/DataTableForQuery.tsx +++ b/Source/DataTables/DataTableForQuery.tsx @@ -173,6 +173,8 @@ export const DataTableForQuery =
diff --git a/Source/DataTables/TablePaginator.css b/Source/DataTables/TablePaginator.css index 6f7f207..a6b5eda 100644 --- a/Source/DataTables/TablePaginator.css +++ b/Source/DataTables/TablePaginator.css @@ -14,3 +14,10 @@ color: var(--cratis-text-color-secondary); padding: 0 0.5rem; } + +.cratis-table-paginator-range { + font-size: 0.8125rem; + color: var(--cratis-text-color-secondary); + margin-right: auto; + padding: 0 0.5rem; +} diff --git a/Source/DataTables/TablePaginator.tsx b/Source/DataTables/TablePaginator.tsx index 9141c9b..51f1c94 100644 --- a/Source/DataTables/TablePaginator.tsx +++ b/Source/DataTables/TablePaginator.tsx @@ -12,6 +12,10 @@ export interface TablePaginatorProps { pageCount: number; /** Invoked with the requested zero-based page. */ onPageChange: (page: number) => void; + /** Total number of records across all pages — enables the "X–Y of Z" range report. */ + totalItems?: number; + /** Rows per page — enables the "X–Y of Z" range report. */ + pageSize?: number; /** Extra class name for the paginator container. */ className?: string; } @@ -23,12 +27,16 @@ export interface TablePaginatorProps { * table's internal pagination — so this renders the page controls wired to a * simple `onPageChange(pageIndex)` callback. */ -export const TablePaginator = ({ page, pageCount, onPageChange, className }: TablePaginatorProps) => { +export const TablePaginator = ({ page, pageCount, onPageChange, totalItems, pageSize, className }: TablePaginatorProps) => { const isFirst = page <= 0; const isLast = page >= pageCount - 1; + const rangeReport = totalItems !== undefined && pageSize !== undefined && totalItems > 0 + ? `${page * pageSize + 1}–${Math.min((page + 1) * pageSize, totalItems)} of ${totalItems}` + : undefined; return (
+ {rangeReport && {rangeReport}} From e52bf65a5846d783a4f708d073909d8487a54583 Mon Sep 17 00:00:00 2001 From: woksin Date: Thu, 16 Jul 2026 12:58:41 +0200 Subject: [PATCH 14/42] Add status & display components (Tag, Badge, Chip, Skeleton, Avatar, ProgressBar) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a Display component group with token-consistent Cratis wrappers over PrimeReact 11's Tag, Badge, Chip (compositional), Skeleton, Avatar (compositional), and ProgressBar (compositional): - Tag / Badge — colored status + count indicators (severity tones) for tables, lists, and detail views. - Chip — labeled, optionally removable pills. - Skeleton — loading placeholders to pair with query isPerforming. - Avatar — image with initials/icon fallback. - ProgressBar — determinate/indeterminate progress. Exposed as the `@cratis/components/Display` subpath, with an overview story. Co-Authored-By: Claude Opus 4.8 (1M context) --- Source/Display/Avatar.tsx | 33 ++++++++++++++ Source/Display/Badge.tsx | 34 ++++++++++++++ Source/Display/Chip.tsx | 35 +++++++++++++++ Source/Display/Display.stories.tsx | 72 ++++++++++++++++++++++++++++++ Source/Display/ProgressBar.tsx | 31 +++++++++++++ Source/Display/Skeleton.tsx | 32 +++++++++++++ Source/Display/Tag.tsx | 36 +++++++++++++++ Source/Display/index.ts | 9 ++++ Source/index.ts | 2 + Source/package.json | 4 ++ 10 files changed, 288 insertions(+) create mode 100644 Source/Display/Avatar.tsx create mode 100644 Source/Display/Badge.tsx create mode 100644 Source/Display/Chip.tsx create mode 100644 Source/Display/Display.stories.tsx create mode 100644 Source/Display/ProgressBar.tsx create mode 100644 Source/Display/Skeleton.tsx create mode 100644 Source/Display/Tag.tsx create mode 100644 Source/Display/index.ts diff --git a/Source/Display/Avatar.tsx b/Source/Display/Avatar.tsx new file mode 100644 index 0000000..624a75f --- /dev/null +++ b/Source/Display/Avatar.tsx @@ -0,0 +1,33 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import React from 'react'; +import { Avatar as PrimeAvatar } from 'primereact/avatar'; + +/** Props for {@link Avatar}. */ +export interface AvatarProps { + /** Image URL. When present, the image is shown; otherwise the label/icon fallback is used. */ + image?: string; + /** Text fallback (e.g. initials) shown when no image is available. */ + label?: string; + /** Icon fallback shown when no image/label is available. */ + icon?: React.ReactNode; + /** Alt text for the image. */ + alt?: string; + /** Avatar size. */ + size?: 'normal' | 'large' | 'xlarge'; + /** Extra class name. */ + className?: string; +} + +/** + * A user/entity avatar built on PrimeReact 11's compositional `Avatar`. Shows + * an image when available and falls back to initials or an icon otherwise. + */ +export const Avatar = ({ image, label, icon, alt, size, className }: AvatarProps) => ( + + {image + ? + : {icon ?? label}} + +); diff --git a/Source/Display/Badge.tsx b/Source/Display/Badge.tsx new file mode 100644 index 0000000..e4e3094 --- /dev/null +++ b/Source/Display/Badge.tsx @@ -0,0 +1,34 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import React from 'react'; +import { Badge as PrimeBadge } from 'primereact/badge'; + +/** Severity tone of a {@link Badge}. */ +export type BadgeSeverity = 'secondary' | 'info' | 'success' | 'warn' | 'danger' | 'contrast'; + +/** Props for {@link Badge}. */ +export interface BadgeProps { + /** The value shown inside the badge (e.g. a count). */ + value?: React.ReactNode; + /** Severity tone (drives the color). */ + severity?: BadgeSeverity; + /** Badge size. */ + size?: 'small' | 'large' | 'xlarge'; + /** When `circle`, renders as a dot/circle badge. */ + shape?: 'circle'; + /** Extra class name. */ + className?: string; + /** Badge content (alternative to {@link value}). */ + children?: React.ReactNode; +} + +/** + * A compact count/status badge built on PrimeReact 11's `Badge`. Use for + * unread counts, notification indicators, and small numeric overlays. + */ +export const Badge = ({ value, severity, size, shape, className, children }: BadgeProps) => ( + + {value ?? children} + +); diff --git a/Source/Display/Chip.tsx b/Source/Display/Chip.tsx new file mode 100644 index 0000000..57b7bfc --- /dev/null +++ b/Source/Display/Chip.tsx @@ -0,0 +1,35 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import React from 'react'; +import { Chip as PrimeChip } from 'primereact/chip'; + +/** Props for {@link Chip}. */ +export interface ChipProps { + /** The chip label. */ + label?: string; + /** An icon rendered before the label. */ + icon?: React.ReactNode; + /** When true, shows a remove control. */ + removable?: boolean; + /** Invoked when the remove control is activated. */ + onRemove?: () => void; + /** Extra class name. */ + className?: string; +} + +/** + * A labeled, optionally-removable chip built on PrimeReact 11's compositional + * `Chip`. Use for filter pills, selected tokens, and tag-like affordances. + */ +export const Chip = ({ label, icon, removable, onRemove, className }: ChipProps) => ( + + {icon && {icon}} + {label} + {removable && ( + + + + )} + +); diff --git a/Source/Display/Display.stories.tsx b/Source/Display/Display.stories.tsx new file mode 100644 index 0000000..ff9dc28 --- /dev/null +++ b/Source/Display/Display.stories.tsx @@ -0,0 +1,72 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import type { Meta, StoryObj } from '@storybook/react'; +import { fn } from 'storybook/test'; +import { Tag } from './Tag'; +import { Badge } from './Badge'; +import { Chip } from './Chip'; +import { Skeleton } from './Skeleton'; +import { Avatar } from './Avatar'; +import { ProgressBar } from './ProgressBar'; + +const meta = { + title: 'Display/Overview', + parameters: { layout: 'padded' }, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const Row = ({ label, children }: { label: string; children: React.ReactNode }) => ( +
+ {label} + {children} +
+); + +/** All status & display components at a glance. */ +export const Overview: Story = { + render: () => ( +
+ + + + + + + + + + + + + + + } /> + + + + } size="large" /> + + +
+ +
+
+ +
+ +
+
+ +
+ + +
+ +
+
+ ), +}; diff --git a/Source/Display/ProgressBar.tsx b/Source/Display/ProgressBar.tsx new file mode 100644 index 0000000..3ac8cbd --- /dev/null +++ b/Source/Display/ProgressBar.tsx @@ -0,0 +1,31 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { ProgressBar as PrimeProgressBar } from 'primereact/progressbar'; + +/** Props for {@link ProgressBar}. */ +export interface ProgressBarProps { + /** Completion value, 0–100. Ignored in `indeterminate` mode. */ + value?: number; + /** `determinate` (default) shows {@link value}; `indeterminate` shows a looping animation. */ + mode?: 'determinate' | 'indeterminate'; + /** Whether to render the percentage label. Defaults to `true` (determinate only). */ + showValue?: boolean; + /** Extra class name. */ + className?: string; +} + +/** + * A horizontal progress indicator built on PrimeReact 11's compositional + * `ProgressBar`. Use `indeterminate` for unknown-duration work and + * `determinate` with a `value` for measurable progress (e.g. an upload). + */ +export const ProgressBar = ({ value, mode = 'determinate', showValue = true, className }: ProgressBarProps) => ( + + + {showValue && mode === 'determinate' && ( + {value ?? 0}% + )} + + +); diff --git a/Source/Display/Skeleton.tsx b/Source/Display/Skeleton.tsx new file mode 100644 index 0000000..681c754 --- /dev/null +++ b/Source/Display/Skeleton.tsx @@ -0,0 +1,32 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { Skeleton as PrimeSkeleton } from 'primereact/skeleton'; + +/** Props for {@link Skeleton}. */ +export interface SkeletonProps { + /** Width, any CSS length. Defaults to `'100%'`. */ + width?: string; + /** Height, any CSS length. Defaults to `'1rem'`. */ + height?: string; + /** Border radius, any CSS length. */ + borderRadius?: string; + /** When true, renders a circle (equal width/height, fully rounded). */ + circle?: boolean; + /** Extra class name. */ + className?: string; +} + +/** + * A loading placeholder built on PrimeReact 11's `Skeleton`. Use to reserve + * layout while a query is loading (e.g. `result.isPerforming`) instead of a + * blank flash or a spinner. + */ +export const Skeleton = ({ width = '100%', height = '1rem', borderRadius, circle, className }: SkeletonProps) => ( + +); diff --git a/Source/Display/Tag.tsx b/Source/Display/Tag.tsx new file mode 100644 index 0000000..8c301b4 --- /dev/null +++ b/Source/Display/Tag.tsx @@ -0,0 +1,36 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import React from 'react'; +import { Tag as PrimeTag } from 'primereact/tag'; + +/** Severity tone of a {@link Tag}. */ +export type TagSeverity = 'secondary' | 'success' | 'info' | 'warn' | 'danger' | 'contrast'; + +/** Props for {@link Tag}. */ +export interface TagProps { + /** The label shown inside the tag. */ + value?: React.ReactNode; + /** Severity tone (drives the color). */ + severity?: TagSeverity; + /** When true, fully rounds the tag. */ + rounded?: boolean; + /** An icon rendered before the label. */ + icon?: React.ReactNode; + /** Extra class name. */ + className?: string; + /** Tag content (alternative to {@link value}). */ + children?: React.ReactNode; +} + +/** + * A small colored status label built on PrimeReact 11's `Tag`. Use for inline + * status indicators in tables, lists, and detail views — e.g. an order state + * or a read-model flag. + */ +export const Tag = ({ value, severity, rounded, icon, className, children }: TagProps) => ( + + {icon} + {value ?? children} + +); diff --git a/Source/Display/index.ts b/Source/Display/index.ts new file mode 100644 index 0000000..dfb47d6 --- /dev/null +++ b/Source/Display/index.ts @@ -0,0 +1,9 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +export * from './Tag'; +export * from './Badge'; +export * from './Chip'; +export * from './Skeleton'; +export * from './Avatar'; +export * from './ProgressBar'; diff --git a/Source/index.ts b/Source/index.ts index e38edc6..4c50a3b 100644 --- a/Source/index.ts +++ b/Source/index.ts @@ -8,6 +8,7 @@ import * as Common from './Common'; import * as DataPage from './DataPage'; import * as DataTables from './DataTables'; import * as Dialogs from './Dialogs'; +import * as Display from './Display'; import * as Dropdown from './Dropdown'; import * as Filter from './Filter'; import * as ObjectContentEditor from './ObjectContentEditor'; @@ -26,6 +27,7 @@ export { DataPage, DataTables, Dialogs, + Display, Dropdown, Filter, ObjectContentEditor, diff --git a/Source/package.json b/Source/package.json index a3babef..33c47ba 100644 --- a/Source/package.json +++ b/Source/package.json @@ -61,6 +61,10 @@ "types": "./dist/esm/Dialogs/index.d.ts", "import": "./dist/esm/Dialogs/index.js" }, + "./Display": { + "types": "./dist/esm/Display/index.d.ts", + "import": "./dist/esm/Display/index.js" + }, "./Dropdown": { "types": "./dist/esm/Dropdown/index.d.ts", "import": "./dist/esm/Dropdown/index.js" From 7322bb16f43283f5e0de319c66ee82ae43d13bbc Mon Sep 17 00:00:00 2001 From: woksin Date: Thu, 16 Jul 2026 13:03:12 +0200 Subject: [PATCH 15/42] Add Toast notifications with Arc command-result integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a Notifications group built on PrimeReact 11's headless Toaster/Toast: - Toaster: the single app-wide toast host — mount one near the app root. Reads the live toasts from the module-level store and renders each with a per-severity icon, title, description, and an auto-wired close button; the auto-dismiss timer is handled by the primitives. Ships baseline token-based styling (severity-colored cards) so notifications look right before a theme preset is applied. - toast: the imperative API (toast.success/info/warn/error/promise/dismiss), callable from anywhere including outside React. - toastCommandResult: surfaces an Arc ICommandResult as a toast, mapping the granular flags to severities — success, not-authorized (warn), validation (error with per-field messages), and exceptions (generic error; stack traces are never shown). The Arc-native way to give command feedback outside a CommandDialog. Exposed as the `@cratis/components/Notifications` subpath, with a story. Co-Authored-By: Claude Opus 4.8 (1M context) --- Source/Notifications/Toaster.css | 61 +++++++++++++++ Source/Notifications/Toaster.stories.tsx | 49 ++++++++++++ Source/Notifications/Toaster.tsx | 83 ++++++++++++++++++++ Source/Notifications/index.ts | 6 ++ Source/Notifications/toast.ts | 17 +++++ Source/Notifications/toastCommandResult.ts | 89 ++++++++++++++++++++++ Source/index.ts | 2 + Source/package.json | 4 + 8 files changed, 311 insertions(+) create mode 100644 Source/Notifications/Toaster.css create mode 100644 Source/Notifications/Toaster.stories.tsx create mode 100644 Source/Notifications/Toaster.tsx create mode 100644 Source/Notifications/index.ts create mode 100644 Source/Notifications/toast.ts create mode 100644 Source/Notifications/toastCommandResult.ts diff --git a/Source/Notifications/Toaster.css b/Source/Notifications/Toaster.css new file mode 100644 index 0000000..3d54bdc --- /dev/null +++ b/Source/Notifications/Toaster.css @@ -0,0 +1,61 @@ +/* Copyright (c) Cratis. All rights reserved. */ +/* Licensed under the MIT license. See LICENSE file in the project root for full license information. */ + +/* Baseline toast card — a notification needs a visible surface even before a + theme preset is applied, so this ships a token-based default that a preset or + `pt` can still override. */ +.cratis-toast { + display: flex; + align-items: flex-start; + gap: 0.625rem; + min-width: 18rem; + max-width: 26rem; + padding: 0.75rem 1rem; + background: var(--cratis-surface-overlay); + color: var(--cratis-text-color); + border: 1px solid var(--cratis-surface-border); + border-left: 4px solid var(--cratis-surface-border); + border-radius: 0.5rem; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35); +} + +.cratis-toast [data-part="content"] { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 0.125rem; +} + +.cratis-toast [data-part="title"] { + font-weight: 600; +} + +.cratis-toast [data-part="description"] { + font-size: 0.875rem; + color: var(--cratis-text-color-secondary); + white-space: pre-line; +} + +.cratis-toast [data-part="close"] { + border: none; + background: transparent; + color: var(--cratis-text-color-secondary); + cursor: pointer; + padding: 0.125rem; + border-radius: 0.25rem; +} + +.cratis-toast [data-part="close"]:hover { + color: var(--cratis-text-color); +} + +.cratis-toast[data-severity="success"] { border-left-color: var(--cratis-green-500); } +.cratis-toast[data-severity="info"] { border-left-color: var(--cratis-primary-color); } +.cratis-toast[data-severity="warn"] { border-left-color: var(--cratis-yellow-500, #eab308); } +.cratis-toast[data-severity="error"] { border-left-color: var(--cratis-red-500); } + +.cratis-toast[data-severity="success"] [data-part="icon"] { color: var(--cratis-green-500); } +.cratis-toast[data-severity="info"] [data-part="icon"] { color: var(--cratis-primary-color); } +.cratis-toast[data-severity="warn"] [data-part="icon"] { color: var(--cratis-yellow-500, #eab308); } +.cratis-toast[data-severity="error"] [data-part="icon"] { color: var(--cratis-red-500); } diff --git a/Source/Notifications/Toaster.stories.tsx b/Source/Notifications/Toaster.stories.tsx new file mode 100644 index 0000000..b145aea --- /dev/null +++ b/Source/Notifications/Toaster.stories.tsx @@ -0,0 +1,49 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import type { Meta, StoryObj } from '@storybook/react'; +import { Button } from 'primereact/button'; +import { Toaster } from './Toaster'; +import { toast } from './toast'; +import { toastCommandResult } from './toastCommandResult'; +import type { ICommandResult } from '@cratis/arc/commands'; + +const meta = { + title: 'Notifications/Toaster', + component: Toaster, + parameters: { layout: 'centered' }, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const makeResult = (over: Partial): ICommandResult => + ({ isSuccess: false, isAuthorized: true, isValid: true, hasExceptions: false, validationResults: [], exceptionMessages: [], response: {}, ...over } as unknown as ICommandResult); + +/** Fire toasts of each severity, plus the Arc command-result helper. */ +export const Playground: Story = { + render: () => ( +
+ + + + + + + +
+ ), +}; diff --git a/Source/Notifications/Toaster.tsx b/Source/Notifications/Toaster.tsx new file mode 100644 index 0000000..12bba6f --- /dev/null +++ b/Source/Notifications/Toaster.tsx @@ -0,0 +1,83 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { Toaster as PrimeToaster, useToasterContext } from 'primereact/toaster'; +import { Toast } from 'primereact/toast'; +import type { ToastType } from '@primereact/types/primitive/toaster'; +import type { ToasterPosition } from '@primereact/types/headless/toaster'; +import './Toaster.css'; + +/** + * Reads the live toasts from the toaster context and renders each one. Must be + * mounted inside `PrimeToaster.Root` (which provides the context). The + * imperative `toast(...)` calls push into the same module-level store this + * subscribes to, so a single mounted region shows toasts from anywhere. + */ +const ToastList = () => { + const toaster = useToasterContext(); + if (!toaster) return null; + + return ( + <> + {toaster.toasts.map((item: ToastType) => ( + + + + + + + + + + + + + + + ))} + + ); +}; + +/** Props for {@link Toaster}. */ +export interface ToasterProps { + /** Corner/edge the toasts stack from. Defaults to `'top-right'`. */ + position?: ToasterPosition; + /** Maximum number of toasts shown at once. Defaults to `3`. */ + limit?: number; + /** Auto-dismiss timeout in milliseconds. Defaults to `6000`. Per-toast `duration` overrides it. */ + timeout?: number; +} + +/** + * The single, app-wide toast host. Mount one `` near your app root; + * then call the imperative {@link toast} (`toast.success(...)`, `toast.error(...)`, + * …) from anywhere — including outside React — and the notification appears + * here and auto-dismisses. + * + * Built on PrimeReact 11's headless Toaster/Toast: a module-level store backs + * the imperative API, and this region subscribes to it. Icons are chosen per + * severity, close/dismiss and the auto-dismiss timer are wired by the + * primitives, and severity is surfaced as `data-severity` on each toast for + * styling. + * + * ```tsx + * // once, near the app root: + * + * + * // anywhere: + * import { toast } from '@cratis/components/Notifications'; + * toast.success({ title: 'Saved', description: 'Your changes were saved.' }); + * ``` + * + * To surface an Arc command result automatically, see {@link toastCommandResult}. + */ +export const Toaster = ({ position = 'top-right', limit = 3, timeout = 6000 }: ToasterProps) => ( + + + + + + + +); diff --git a/Source/Notifications/index.ts b/Source/Notifications/index.ts new file mode 100644 index 0000000..d061939 --- /dev/null +++ b/Source/Notifications/index.ts @@ -0,0 +1,6 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +export * from './Toaster'; +export * from './toast'; +export * from './toastCommandResult'; diff --git a/Source/Notifications/toast.ts b/Source/Notifications/toast.ts new file mode 100644 index 0000000..7a663cd --- /dev/null +++ b/Source/Notifications/toast.ts @@ -0,0 +1,17 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +/** + * The imperative toast API. Call `toast(...)` for a plain toast, or + * `toast.success/info/warn/error(...)` for a severity, `toast.promise(...)` to + * track a promise, and `toast.dismiss(id?)` to close. Requires a {@link Toaster} + * mounted somewhere in the tree. + * + * ```tsx + * import { toast } from '@cratis/components/Notifications'; + * toast.success({ title: 'Saved' }); + * toast.error({ title: 'Failed', description: 'Please try again.' }); + * ``` + */ +export { toast } from 'primereact/toaster'; +export type { ToastType, ToastSeverity, ToastId } from '@primereact/types/primitive/toaster'; diff --git a/Source/Notifications/toastCommandResult.ts b/Source/Notifications/toastCommandResult.ts new file mode 100644 index 0000000..ef0ea94 --- /dev/null +++ b/Source/Notifications/toastCommandResult.ts @@ -0,0 +1,89 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import type { ICommandResult } from '@cratis/arc/commands'; +import { toast } from 'primereact/toaster'; + +/** Options for {@link toastCommandResult}. */ +export interface ToastCommandResultOptions { + /** Title for the success toast. Defaults to `'Success'`. */ + successTitle?: string; + /** Description for the success toast. */ + successDescription?: string; + /** Title when the command was rejected by authorization. Defaults to `'Not authorized'`. */ + unauthorizedTitle?: string; + /** Title when the command failed validation. Defaults to `'Validation failed'`. */ + validationTitle?: string; + /** Title when the command threw. Defaults to `'Something went wrong'`. */ + exceptionTitle?: string; + /** When false, no toast is shown on success. Defaults to `true`. */ + showSuccess?: boolean; +} + +/** + * Surfaces an Arc {@link ICommandResult} as a toast, mapping the granular + * result flags to the right severity — the same branching Arc apps do when + * executing a command outside a `CommandDialog`: + * + * - **success** → a success toast (suppress with `showSuccess: false`); + * - **not authorized** → a warning toast; + * - **invalid** → an error toast listing the per-field validation messages; + * - **exceptions** → a generic error toast (stack traces are never shown to + * users — log `result.exceptionMessages` yourself). + * + * Requires a {@link Toaster} mounted in the tree. Returns `true` on success so + * callers can gate follow-up work (close a panel, refresh a query). + * + * ```tsx + * const result = await command.execute(); + * if (toastCommandResult(result, { successTitle: 'Author registered' })) { + * refresh(); + * } + * ``` + * + * @typeParam TResponse - The command response type. + * @param result - The command result to surface. + * @param options - {@link ToastCommandResultOptions}. + * @returns `true` when the command succeeded, otherwise `false`. + */ +export function toastCommandResult( + result: ICommandResult, + options: ToastCommandResultOptions = {} +): boolean { + const { + successTitle = 'Success', + successDescription, + unauthorizedTitle = 'Not authorized', + validationTitle = 'Validation failed', + exceptionTitle = 'Something went wrong', + showSuccess = true, + } = options; + + if (result.isSuccess) { + if (showSuccess) { + toast.success({ title: successTitle, description: successDescription }); + } + return true; + } + + if (!result.isAuthorized) { + toast.warn({ title: unauthorizedTitle }); + return false; + } + + if (!result.isValid) { + const description = result.validationResults + .map(validationResult => validationResult.message) + .filter(Boolean) + .join('\n'); + toast.error({ title: validationTitle, description: description || undefined }); + return false; + } + + if (result.hasExceptions) { + toast.error({ title: exceptionTitle }); + return false; + } + + return false; +} diff --git a/Source/index.ts b/Source/index.ts index 4c50a3b..52135e6 100644 --- a/Source/index.ts +++ b/Source/index.ts @@ -11,6 +11,7 @@ import * as Dialogs from './Dialogs'; import * as Display from './Display'; import * as Dropdown from './Dropdown'; import * as Filter from './Filter'; +import * as Notifications from './Notifications'; import * as ObjectContentEditor from './ObjectContentEditor'; import * as ObjectNavigationalBar from './ObjectNavigationalBar'; import * as PivotViewer from './PivotViewer'; @@ -30,6 +31,7 @@ export { Display, Dropdown, Filter, + Notifications, ObjectContentEditor, ObjectNavigationalBar, PivotViewer, diff --git a/Source/package.json b/Source/package.json index 33c47ba..7e15070 100644 --- a/Source/package.json +++ b/Source/package.json @@ -73,6 +73,10 @@ "types": "./dist/esm/Filter/index.d.ts", "import": "./dist/esm/Filter/index.js" }, + "./Notifications": { + "types": "./dist/esm/Notifications/index.d.ts", + "import": "./dist/esm/Notifications/index.js" + }, "./ObjectContentEditor": { "types": "./dist/esm/ObjectContentEditor/index.d.ts", "import": "./dist/esm/ObjectContentEditor/index.js" From b5d5ecf1e5b475e201a44c63f97f6430dfc642bc Mon Sep 17 00:00:00 2001 From: woksin Date: Thu, 16 Jul 2026 13:08:44 +0200 Subject: [PATCH 16/42] Add PasswordField, ToggleSwitchField, and RatingField MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round out the CommandForm field catalog with three more first-class fields, each wired through asCommandFormField so they participate in validation and change tracking like the built-ins: - PasswordField — a masked password input with a show/hide toggle (string). - ToggleSwitchField — a boolean on/off switch, the toggle counterpart of CheckboxField. - RatingField — a star-rating input (number). Exported from @cratis/components/CommandForm, with a bound story. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../CommandForm/fields/NewFields.stories.tsx | 82 +++++++++++++++++++ Source/CommandForm/fields/PasswordField.tsx | 51 ++++++++++++ Source/CommandForm/fields/RatingField.tsx | 54 ++++++++++++ .../CommandForm/fields/ToggleSwitchField.tsx | 54 ++++++++++++ Source/CommandForm/fields/index.ts | 3 + 5 files changed, 244 insertions(+) create mode 100644 Source/CommandForm/fields/NewFields.stories.tsx create mode 100644 Source/CommandForm/fields/PasswordField.tsx create mode 100644 Source/CommandForm/fields/RatingField.tsx create mode 100644 Source/CommandForm/fields/ToggleSwitchField.tsx diff --git a/Source/CommandForm/fields/NewFields.stories.tsx b/Source/CommandForm/fields/NewFields.stories.tsx new file mode 100644 index 0000000..5e3db82 --- /dev/null +++ b/Source/CommandForm/fields/NewFields.stories.tsx @@ -0,0 +1,82 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import type { Meta, StoryObj } from '@storybook/react'; +import { CommandForm } from '@cratis/arc.react/commands'; +import { Command, CommandResult, CommandValidator } from '@cratis/arc/commands'; +import { PropertyDescriptor } from '@cratis/arc/reflection'; +import { PasswordField } from './PasswordField'; +import { ToggleSwitchField } from './ToggleSwitchField'; +import { RatingField } from './RatingField'; +import '@cratis/arc/validation'; + +const meta: Meta = { + title: 'CommandForm/NewFields', +}; + +export default meta; +type Story = StoryObj; + +class ProfileValidator extends CommandValidator { + constructor() { + super(); + this.ruleFor((c: ProfileCommand) => c.password).notEmpty().minLength(6); + } +} + +class ProfileCommand extends Command { + readonly route: string = '/api/profile'; + readonly validation: CommandValidator = new ProfileValidator(); + readonly propertyDescriptors: PropertyDescriptor[] = [ + new PropertyDescriptor('password', String), + new PropertyDescriptor('notifications', Boolean), + new PropertyDescriptor('rating', Number), + ]; + + password = ''; + notifications = false; + rating = 0; + + constructor() { + super(Object, false); + } + + get requestParameters(): string[] { + return []; + } + + get properties(): string[] { + return ['password', 'notifications', 'rating']; + } + + override async validate(): Promise> { + const errors = this.validation?.validate(this) ?? []; + return errors.length > 0 ? CommandResult.validationFailed(errors) : CommandResult.empty; + } + + override async execute(): Promise> { + const validation = await this.validate(); + return validation.isSuccess ? CommandResult.empty : validation; + } +} + +/** The new PasswordField, ToggleSwitchField, and RatingField bound to a command. */ +export const Overview: Story = { + render: () => ( +
+ command={ProfileCommand} autoServerValidate={false} validateOn="change"> +
+ + value={c => c.password} placeholder="At least 6 characters" /> +
+
+ value={c => c.notifications} label="Enable notifications" /> +
+
+ + value={c => c.rating} stars={5} /> +
+ +
+ ), +}; diff --git a/Source/CommandForm/fields/PasswordField.tsx b/Source/CommandForm/fields/PasswordField.tsx new file mode 100644 index 0000000..a69d4eb --- /dev/null +++ b/Source/CommandForm/fields/PasswordField.tsx @@ -0,0 +1,51 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { InputPassword } from 'primereact/inputpassword'; +import type { InputPasswordProps, InputPasswordValueChangeEvent } from '@primereact/types/primitive/inputpassword'; +import React from 'react'; +import { asCommandFormField, WrappedFieldProps } from '@cratis/arc.react/commands'; + +/** Component-level props for {@link PasswordField}. */ +interface PasswordFieldComponentProps extends WrappedFieldProps { + /** Placeholder text. */ + placeholder?: string; + /** Extra CSS class name combined with the default `w-full`. */ + className?: string; + /** PrimeReact pass-through configuration applied to the underlying InputPassword. */ + pt?: InputPasswordProps['pt']; + /** PrimeReact pass-through options applied to the underlying InputPassword. */ + ptOptions?: InputPasswordProps['ptOptions']; + /** When true, disables every base PrimeReact style on the underlying InputPassword. */ + unstyled?: boolean; +} + +/** + * A masked password field bound to a `string` property on a Cratis Arc command, + * with a built-in show/hide toggle. See {@link InputTextField} for the full + * `value={c => c.prop}` binding model. + * + * ```tsx + * c.password} title="Password" /> + * ``` + */ +export const PasswordField = asCommandFormField( + (props) => ( +
+ +
+ ), + { + defaultValue: '', + extractValue: (e: InputPasswordValueChangeEvent) => e.value + } +); diff --git a/Source/CommandForm/fields/RatingField.tsx b/Source/CommandForm/fields/RatingField.tsx new file mode 100644 index 0000000..55341c8 --- /dev/null +++ b/Source/CommandForm/fields/RatingField.tsx @@ -0,0 +1,54 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { Rating } from 'primereact/rating'; +import type { RatingRootProps, RatingRootValueChangeEvent } from '@primereact/types/primitive/rating'; +import React from 'react'; +import { asCommandFormField, WrappedFieldProps } from '@cratis/arc.react/commands'; + +/** Component-level props for {@link RatingField}. */ +interface RatingFieldComponentProps extends WrappedFieldProps { + /** Number of stars. Defaults to `5`. */ + stars?: number; + /** Extra CSS class name. */ + className?: string; + /** PrimeReact pass-through configuration applied to the underlying Rating. */ + pt?: RatingRootProps['pt']; + /** PrimeReact pass-through options applied to the underlying Rating. */ + ptOptions?: RatingRootProps['ptOptions']; + /** When true, disables every base PrimeReact style on the underlying Rating. */ + unstyled?: boolean; +} + +/** + * A star-rating field bound to a `number` property on a Cratis Arc command. + * See {@link InputTextField} for the full `value={c => c.prop}` binding model. + * + * ```tsx + * c.rating} title="Rating" stars={5} /> + * ``` + */ +export const RatingField = asCommandFormField( + (props) => ( +
+ + {Array.from({ length: props.stars ?? 5 }, (_, index) => ( + + + + + ))} + +
+ ), + { + defaultValue: 0, + extractValue: (e: RatingRootValueChangeEvent) => e.value + } +); diff --git a/Source/CommandForm/fields/ToggleSwitchField.tsx b/Source/CommandForm/fields/ToggleSwitchField.tsx new file mode 100644 index 0000000..2e10b35 --- /dev/null +++ b/Source/CommandForm/fields/ToggleSwitchField.tsx @@ -0,0 +1,54 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { ToggleSwitch } from 'primereact/toggleswitch'; +import type { ToggleSwitchRootProps, ToggleSwitchRootChangeEvent } from '@primereact/types/primitive/toggleswitch'; +import React from 'react'; +import { asCommandFormField, WrappedFieldProps } from '@cratis/arc.react/commands'; + +/** Component-level props for {@link ToggleSwitchField}. */ +interface ToggleSwitchFieldComponentProps extends WrappedFieldProps { + /** Optional label displayed next to the switch. */ + label?: string; + /** Extra CSS class name forwarded to the underlying ToggleSwitch. */ + className?: string; + /** PrimeReact pass-through configuration applied to the underlying ToggleSwitch. */ + pt?: ToggleSwitchRootProps['pt']; + /** PrimeReact pass-through options applied to the underlying ToggleSwitch. */ + ptOptions?: ToggleSwitchRootProps['ptOptions']; + /** When true, disables every base PrimeReact style on the underlying ToggleSwitch. */ + unstyled?: boolean; +} + +/** + * A boolean on/off switch field bound to a `boolean` property on a Cratis Arc + * command — the toggle counterpart of {@link CheckboxField}. See + * {@link InputTextField} for the full `value={c => c.prop}` binding model. + * + * ```tsx + * c.notificationsEnabled} label="Notifications" /> + * ``` + */ +export const ToggleSwitchField = asCommandFormField( + (props) => ( +
+ + + + + + {props.label && } +
+ ), + { + defaultValue: false, + extractValue: (e: ToggleSwitchRootChangeEvent) => e.checked + } +); diff --git a/Source/CommandForm/fields/index.ts b/Source/CommandForm/fields/index.ts index 6482418..55b6e9c 100644 --- a/Source/CommandForm/fields/index.ts +++ b/Source/CommandForm/fields/index.ts @@ -13,3 +13,6 @@ export * from './MultiSelectField'; export * from './ChipsField'; export * from './RadioButtonField'; export * from './RadioGroupField'; +export * from './PasswordField'; +export * from './ToggleSwitchField'; +export * from './RatingField'; From ba0cf5d98481e42f79794ca0f26000638e400f2e Mon Sep 17 00:00:00 2001 From: woksin Date: Thu, 16 Jul 2026 16:43:41 +0200 Subject: [PATCH 17/42] Add consumer migration guide and BDD specs for the new logic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MIGRATION.md: a v0.1 → v0.2 upgrade guide for consuming apps — the ESM-only note, the removed-import find-and-replace table (column/stepperpanel/menubar/ dropdown), the selection-event rename, the theming change (preset or the upcoming Cratis baseline theme), and the licensing summary. - Extract the data-table selection translation (selectionKeysForRow / rowFromSelectionKeys) and the paginator range formatter (paginatorRange) into pure, testable helpers, and use them from DataTableCore / TablePaginator. - Add BDD specs: toastCommandResult across every result outcome (success, suppressed, unauthorized, validation with per-field messages, exceptions with no leaked stack trace), the selection key↔row translation, and the paginator range boundaries. 209 specs pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- Source/DataTables/DataTableCore.tsx | 15 +-- Source/DataTables/TablePaginator.tsx | 5 +- .../when_formatting_the_range.ts | 28 +++++ .../when_building_keys_for_a_row.ts | 34 ++++++ .../when_resolving_a_row_from_keys.ts | 34 ++++++ Source/DataTables/paginatorRange.ts | 18 +++ Source/DataTables/selectionKeys.ts | 39 ++++++ Source/MIGRATION.md | 111 ++++++++++++++++++ .../when_toasting_a_command_result.ts | 98 ++++++++++++++++ 9 files changed, 368 insertions(+), 14 deletions(-) create mode 100644 Source/DataTables/for_paginatorRange/when_formatting_the_range.ts create mode 100644 Source/DataTables/for_selectionKeys/when_building_keys_for_a_row.ts create mode 100644 Source/DataTables/for_selectionKeys/when_resolving_a_row_from_keys.ts create mode 100644 Source/DataTables/paginatorRange.ts create mode 100644 Source/DataTables/selectionKeys.ts create mode 100644 Source/MIGRATION.md create mode 100644 Source/Notifications/for_toastCommandResult/when_toasting_a_command_result.ts diff --git a/Source/DataTables/DataTableCore.tsx b/Source/DataTables/DataTableCore.tsx index f55ba2e..d4b709d 100644 --- a/Source/DataTables/DataTableCore.tsx +++ b/Source/DataTables/DataTableCore.tsx @@ -5,9 +5,10 @@ import React, { useMemo, useState, type CSSProperties, type ReactNode } from 're import { DataTable as PrimeDataTable } from 'primereact/datatable'; import { InputText } from 'primereact/inputtext'; import type { DataTableRootProps } from '@primereact/types/primitive/datatable'; -import type { SelectionKeys, UseDataTableSelectionEvent, UseDataTableRowMouseEvent, UseDataTableFilterEvent } from '@primereact/types/headless/datatable'; +import type { UseDataTableSelectionEvent, UseDataTableRowMouseEvent, UseDataTableFilterEvent } from '@primereact/types/headless/datatable'; import type { ColumnProps } from './Column'; import { ColumnFilterMenu } from './ColumnFilterMenu'; +import { selectionKeysForRow, rowFromSelectionKeys } from './selectionKeys'; import type { DataTableSelectionChangeEvent } from './DataTableSelectionChangeEvent'; import type { DataTableFilterMeta } from './DataTableFilterMeta'; import './DataTableCore.css'; @@ -139,19 +140,11 @@ export const DataTableCore = ({ const keyOf = (row: TData): string | undefined => dataKey ? String((row as Record)[dataKey]) : undefined; - const selectionKeys: SelectionKeys = useMemo(() => { - if (!selection || !dataKey) return {}; - const key = String((selection as Record)[dataKey]); - return { [key]: true }; - }, [selection, dataKey]); + const selectionKeys = useMemo(() => selectionKeysForRow(selection, dataKey), [selection, dataKey]); const handleSelectionChange = (event: UseDataTableSelectionEvent) => { if (!onSelectionChange) return; - const selectedKey = Object.keys(event.value).find(key => event.value[key]); - const row = selectedKey !== undefined - ? data.find(candidate => keyOf(candidate) === selectedKey) ?? null - : null; - onSelectionChange({ value: row, originalEvent: event.originalEvent }); + onSelectionChange({ value: rowFromSelectionKeys(event.value, data, dataKey), originalEvent: event.originalEvent }); }; const handleRowClick = onRowClick diff --git a/Source/DataTables/TablePaginator.tsx b/Source/DataTables/TablePaginator.tsx index 51f1c94..65a0ba2 100644 --- a/Source/DataTables/TablePaginator.tsx +++ b/Source/DataTables/TablePaginator.tsx @@ -2,6 +2,7 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. import { Button } from 'primereact/button'; +import { paginatorRange } from './paginatorRange'; import './TablePaginator.css'; /** Props for {@link TablePaginator}. */ @@ -30,9 +31,7 @@ export interface TablePaginatorProps { export const TablePaginator = ({ page, pageCount, onPageChange, totalItems, pageSize, className }: TablePaginatorProps) => { const isFirst = page <= 0; const isLast = page >= pageCount - 1; - const rangeReport = totalItems !== undefined && pageSize !== undefined && totalItems > 0 - ? `${page * pageSize + 1}–${Math.min((page + 1) * pageSize, totalItems)} of ${totalItems}` - : undefined; + const rangeReport = paginatorRange(page, pageSize, totalItems); return (
diff --git a/Source/DataTables/for_paginatorRange/when_formatting_the_range.ts b/Source/DataTables/for_paginatorRange/when_formatting_the_range.ts new file mode 100644 index 0000000..bb174e6 --- /dev/null +++ b/Source/DataTables/for_paginatorRange/when_formatting_the_range.ts @@ -0,0 +1,28 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { paginatorRange } from '../paginatorRange'; + +describe('when formatting the paginator range on the first page', () => { + it('should report the first page span', () => { + (paginatorRange(0, 20, 48) ?? '').should.equal('1–20 of 48'); + }); +}); + +describe('when formatting the paginator range on the last partial page', () => { + it('should clamp the end to the total', () => { + (paginatorRange(2, 20, 48) ?? '').should.equal('41–48 of 48'); + }); +}); + +describe('when formatting the paginator range with no items', () => { + it('should return undefined', () => { + (paginatorRange(0, 20, 0) === undefined).should.be.true; + }); +}); + +describe('when formatting the paginator range with unknown totals', () => { + it('should return undefined', () => { + (paginatorRange(0, undefined, undefined) === undefined).should.be.true; + }); +}); diff --git a/Source/DataTables/for_selectionKeys/when_building_keys_for_a_row.ts b/Source/DataTables/for_selectionKeys/when_building_keys_for_a_row.ts new file mode 100644 index 0000000..07f921b --- /dev/null +++ b/Source/DataTables/for_selectionKeys/when_building_keys_for_a_row.ts @@ -0,0 +1,34 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { selectionKeysForRow } from '../selectionKeys'; + +interface Row { id: number; name: string; } + +describe('when building selection keys for a selected row', () => { + let keys: Record; + + beforeEach(() => { + keys = selectionKeysForRow({ id: 7, name: 'Bob' }, 'id'); + }); + + it('should mark the stringified dataKey value as selected', () => { + keys['7'].should.be.true; + }); + + it('should contain exactly one key', () => { + Object.keys(keys).should.have.lengthOf(1); + }); +}); + +describe('when building selection keys with nothing selected', () => { + it('should return an empty map', () => { + Object.keys(selectionKeysForRow(null, 'id')).should.have.lengthOf(0); + }); +}); + +describe('when building selection keys without a dataKey', () => { + it('should return an empty map', () => { + Object.keys(selectionKeysForRow({ id: 7, name: 'Bob' }, undefined)).should.have.lengthOf(0); + }); +}); diff --git a/Source/DataTables/for_selectionKeys/when_resolving_a_row_from_keys.ts b/Source/DataTables/for_selectionKeys/when_resolving_a_row_from_keys.ts new file mode 100644 index 0000000..ac1e44a --- /dev/null +++ b/Source/DataTables/for_selectionKeys/when_resolving_a_row_from_keys.ts @@ -0,0 +1,34 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { rowFromSelectionKeys } from '../selectionKeys'; + +interface Row { id: number; name: string; } + +const data: Row[] = [{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }, { id: 3, name: 'Carol' }]; + +describe('when resolving a row from a selection-keys map with a selected key', () => { + let row: Row | null; + + beforeEach(() => { + row = rowFromSelectionKeys({ '2': true }, data, 'id'); + }); + + it('should return the matching row', () => { + (row?.name ?? '').should.equal('Bob'); + }); +}); + +describe('when resolving a row from a selection-keys map with no selected key', () => { + it('should return null', () => { + const result = rowFromSelectionKeys({ '2': false }, data, 'id'); + (result === null).should.be.true; + }); +}); + +describe('when resolving a row from a selection-keys map whose key is not in the data', () => { + it('should return null', () => { + const result = rowFromSelectionKeys({ '99': true }, data, 'id'); + (result === null).should.be.true; + }); +}); diff --git a/Source/DataTables/paginatorRange.ts b/Source/DataTables/paginatorRange.ts new file mode 100644 index 0000000..7729d99 --- /dev/null +++ b/Source/DataTables/paginatorRange.ts @@ -0,0 +1,18 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +/** + * Formats the "X–Y of Z" range report for a zero-based page, or `undefined` + * when the totals are unknown or there are no records. The end is clamped to + * the total so the last (partial) page reports correctly. + * + * @param page - Zero-based current page. + * @param pageSize - Rows per page. + * @param totalItems - Total records across all pages. + */ +export function paginatorRange(page: number, pageSize: number | undefined, totalItems: number | undefined): string | undefined { + if (totalItems === undefined || pageSize === undefined || totalItems <= 0) return undefined; + const start = page * pageSize + 1; + const end = Math.min((page + 1) * pageSize, totalItems); + return `${start}–${end} of ${totalItems}`; +} diff --git a/Source/DataTables/selectionKeys.ts b/Source/DataTables/selectionKeys.ts new file mode 100644 index 0000000..61e1c25 --- /dev/null +++ b/Source/DataTables/selectionKeys.ts @@ -0,0 +1,39 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import type { SelectionKeys } from '@primereact/types/headless/datatable'; + +/** + * Builds PrimeReact 11's key-based selection map for a single selected row — + * `{ [String(row[dataKey])]: true }` — or an empty map when nothing is + * selected or no `dataKey` is set. This is the write side of the object↔key + * translation the Cratis tables use to keep a row-object selection API over + * v11's key-based model. + * + * @typeParam TData - The row type. + */ +export function selectionKeysForRow( + selection: TData | null | undefined, + dataKey: string | undefined +): SelectionKeys { + if (!selection || !dataKey) return {}; + return { [String((selection as Record)[dataKey])]: true }; +} + +/** + * The read side: resolves the row object matching the single `true` key in a + * PrimeReact 11 selection-keys map, or `null` when the selection is cleared or + * the key is not in `data`. + * + * @typeParam TData - The row type. + */ +export function rowFromSelectionKeys( + keys: SelectionKeys, + data: TData[], + dataKey: string | undefined +): TData | null { + if (!dataKey) return null; + const selectedKey = Object.keys(keys).find(key => keys[key]); + if (selectedKey === undefined) return null; + return data.find(row => String((row as Record)[dataKey]) === selectedKey) ?? null; +} diff --git a/Source/MIGRATION.md b/Source/MIGRATION.md new file mode 100644 index 0000000..7018fc6 --- /dev/null +++ b/Source/MIGRATION.md @@ -0,0 +1,111 @@ +# Migrating `@cratis/components` v0.1 → v0.2 (PrimeReact 10 → 11) + +`@cratis/components` 0.2 moves from PrimeReact 10 to **PrimeReact 11**. Most +wrapper APIs are unchanged, so many apps upgrade with only the find-and-replace +below. This guide lists every change a consuming app might feel. + +> **TL;DR:** the package is now **ESM-only**; a handful of `primereact/*` imports +> moved to `@cratis/components/*`; and styled themes are applied via a +> `@primeuix/themes` preset (or the new Cratis baseline theme) instead of a +> `resources/themes/*.css` import. See **Licensing** at the bottom. + +--- + +## 1. ESM-only packaging + +PrimeReact 11 is ESM-only, so `@cratis/components` dropped its CommonJS build. + +- If your app already bundles with Vite / modern tooling (the Cratis default), + **no change is needed**. +- If something in your pipeline `require()`d the package, switch it to `import`. + +## 2. Import moves (removed `primereact/*` paths) + +PrimeReact 11 removed several modules. Replace them with the Cratis-owned +equivalents — the authoring model is unchanged: + +| Was (PrimeReact 10) | Now (v0.2) | +|---|---| +| `import { Column } from 'primereact/column'` | `import { Column } from '@cratis/components/DataPage'` (or `@cratis/components/DataTables`) | +| `import { StepperPanel } from 'primereact/stepperpanel'` | `import { StepperPanel } from '@cratis/components/CommandDialog'` | +| `import { Menubar } from 'primereact/menubar'` | use `` for list-page actions, or a `Button` toolbar | +| `import { Dropdown } from 'primereact/dropdown'` | `import { Dropdown } from '@cratis/components/Dropdown'` | +| `primereact/calendar`, `primereact/inputtextarea` | used internally; consume via the `CommandForm` fields (`CalendarField`, `TextAreaField`) | + +`` and +`` work exactly as before. + +## 3. Data-table selection event + +The removed `DataTableSelectionSingleChangeEvent` is replaced by +`DataTableSelectionChangeEvent`. The `event.value` (the selected row) is +unchanged, so only the type import changes: + +```diff +- import { DataTableSelectionSingleChangeEvent } from 'primereact/datatable'; +- onSelectionChange={(e: DataTableSelectionSingleChangeEvent) => setSelected(e.value as Product)} ++ import type { DataTableSelectionChangeEvent } from '@cratis/components/DataTables'; ++ onSelectionChange={(e: DataTableSelectionChangeEvent) => setSelected(e.value ?? undefined)} +``` + +`` per-column filter menus, a global search box, and a paginator +range report are all restored/added — no API change to opt in beyond `filter`. + +## 4. Theming — no more `resources/themes/*.css` + +PrimeReact 11 removed the v10 theme stylesheets. Pick one: + +- **Unstyled-first (default, no license):** ship structure + the `--cratis-*` + token layer and bring your own visuals via `pt` / CSS / Tailwind. Your existing + `--surface-*` / `--cratis-*` overrides keep working. +- **Cratis baseline theme (no license):** `import '@cratis/components/theme'` for + a polished default look built entirely on the Cratis tokens. +- **A styled `@primeuix/themes` preset (license-gated — see below):** + + ```diff + - import 'primereact/resources/themes/lara-dark-blue/theme.css'; + + import Aura from '@primeuix/themes/aura'; + // … + - + + + ``` + +If you dropped in the raw `PrimeReactProvider`, it now comes from +`@primereact/core` (not `primereact/api`). + +## 5. Dialog + +Existing `Dialog` / `CommandDialog` / `StepperCommandDialog` APIs are unchanged. +`Dialog` gains an additive `dismissable` prop; `resizable` is accepted but has no +effect (PrimeReact 11's headless dialog has no built-in resize handle). + +## 6. Stepper + +`CommandStepper` / `StepperCommandDialog` keep their public props, including +`orientation` (horizontal/vertical), `headerPosition`, and `start` / `end`. The +`StepperCustomizationProps` type is now Cratis-owned (it no longer aliases +PrimeReact's `StepperProps`) — if you imported it, the shape is the same minus +the removed slots. + +## 7. What's new (nothing to migrate — just available) + +- **`@cratis/components/Notifications`** — `Toaster`, the imperative `toast`, and + `toastCommandResult(result)` to surface an Arc command result as a toast. +- **`@cratis/components/Display`** — `Tag`, `Badge`, `Chip`, `Skeleton`, + `Avatar`, `ProgressBar`. +- **CommandForm fields** — `PasswordField`, `ToggleSwitchField`, `RatingField`. + +--- + +## Licensing (read this) + +PrimeReact 11 changed its licensing: + +- **Unstyled core + the Cratis token layer + `pt` are free** — no key needed. +- **The styled `@primeuix/themes` presets are license-gated.** Applying a preset + needs a **PrimeUI license key** (free community tier or paid); without one, + PrimeReact shows an *"Invalid PrimeUI License"* banner in dev **and** prod. + Supply your key through `CratisComponentsProvider`'s `license` prop. + +**If you use unstyled-first or the Cratis baseline theme, you need no license.** +Only a bundled `@primeuix/themes` preset requires a (free or paid) PrimeUI key. diff --git a/Source/Notifications/for_toastCommandResult/when_toasting_a_command_result.ts b/Source/Notifications/for_toastCommandResult/when_toasting_a_command_result.ts new file mode 100644 index 0000000..44f6634 --- /dev/null +++ b/Source/Notifications/for_toastCommandResult/when_toasting_a_command_result.ts @@ -0,0 +1,98 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { vi } from 'vitest'; +import type { ICommandResult } from '@cratis/arc/commands'; + +// One file for all outcomes: the project runs specs with `isolate: false`, so a +// single module load + one mock avoids the cross-file mock caching that +// separate files (or vi.resetModules) would run into. +const { calls } = vi.hoisted(() => ({ calls: { success: [] as { title: string; description?: string }[], warn: [] as { title: string }[], error: [] as { title: string; description?: string }[] } })); + +vi.mock('primereact/toaster', () => ({ + toast: { + success: (toast: { title: string; description?: string }) => calls.success.push(toast), + warn: (toast: { title: string }) => calls.warn.push(toast), + error: (toast: { title: string; description?: string }) => calls.error.push(toast), + }, +})); + +import { toastCommandResult } from '../toastCommandResult'; + +const result = (over: Partial): ICommandResult => + ({ isSuccess: false, isAuthorized: true, isValid: true, hasExceptions: false, validationResults: [], exceptionMessages: [], ...over } as unknown as ICommandResult); + +const reset = () => { calls.success = []; calls.warn = []; calls.error = []; }; + +describe('when toasting a command result and the command succeeded', () => { + let returned: boolean; + beforeEach(() => { reset(); returned = toastCommandResult(result({ isSuccess: true }), { successTitle: 'Saved' }); }); + + it('should show a success toast with the provided title', () => { + calls.success.should.have.lengthOf(1); + calls.success[0].title.should.equal('Saved'); + }); + it('should not show a warning or error toast', () => { + calls.warn.should.have.lengthOf(0); + calls.error.should.have.lengthOf(0); + }); + it('should return true', () => returned.should.be.true); +}); + +describe('when toasting a successful command result with success suppressed', () => { + let returned: boolean; + beforeEach(() => { reset(); returned = toastCommandResult(result({ isSuccess: true }), { showSuccess: false }); }); + + it('should show no toast', () => { + calls.success.should.have.lengthOf(0); + calls.warn.should.have.lengthOf(0); + calls.error.should.have.lengthOf(0); + }); + it('should still return true', () => returned.should.be.true); +}); + +describe('when toasting a command result that was not authorized', () => { + let returned: boolean; + beforeEach(() => { reset(); returned = toastCommandResult(result({ isAuthorized: false })); }); + + it('should show a warning toast', () => calls.warn.should.have.lengthOf(1)); + it('should not show a success or error toast', () => { + calls.success.should.have.lengthOf(0); + calls.error.should.have.lengthOf(0); + }); + it('should return false', () => returned.should.be.false); +}); + +describe('when toasting a command result that failed validation', () => { + let returned: boolean; + beforeEach(() => { + reset(); + returned = toastCommandResult( + result({ isValid: false, validationResults: [{ message: 'Name is required' }, { message: 'Email is invalid' }] as unknown as ICommandResult['validationResults'] }), + { validationTitle: 'Could not save' }); + }); + + it('should show an error toast with the validation title', () => { + calls.error.should.have.lengthOf(1); + calls.error[0].title.should.equal('Could not save'); + }); + it('should list every validation message in the description', () => { + (calls.error[0].description ?? '').should.contain('Name is required'); + (calls.error[0].description ?? '').should.contain('Email is invalid'); + }); + it('should return false', () => returned.should.be.false); +}); + +describe('when toasting a command result that threw an exception', () => { + let returned: boolean; + beforeEach(() => { reset(); returned = toastCommandResult(result({ hasExceptions: true, exceptionMessages: ['Boom at line 42'] }), { exceptionTitle: 'Something broke' }); }); + + it('should show a generic error toast', () => { + calls.error.should.have.lengthOf(1); + calls.error[0].title.should.equal('Something broke'); + }); + it('should not leak the exception messages into the toast', () => { + (calls.error[0].description ?? '').should.not.contain('Boom'); + }); + it('should return false', () => returned.should.be.false); +}); From 708b40b0812a193aec80829340700c359a91da6e Mon Sep 17 00:00:00 2001 From: woksin Date: Thu, 16 Jul 2026 16:59:59 +0200 Subject: [PATCH 18/42] Add a license-free Cratis baseline theme PrimeReact 11's good-looking styled presets are gated behind a PrimeUI license, which left the license-free (unstyled) path looking plain. Add a token-based baseline theme so apps get a polished default look with no license. - theme.css: styles every component from the --cratis-* token layer (buttons, inputs, checkbox/radio/toggle/slider, select, dialog, datatable, tag/badge/ chip/avatar/progressbar/skeleton, stepper), scoped under `.cratis-theme` with light + dark palettes. It supplies concrete colors that defer to a preset's --p-* tokens when one is present, so the license-free path finally resolves. - Exposed as the `@cratis/components/theme` subpath (shipped by copy-css). - Surface `data-severity` on Tag/Badge so severity coloring works unstyled. - Repurpose the Storybook `cratis-theme` mode to demo it (unstyled, no license), and document it in the README. Verified in Storybook: Display, form fields, DataTable, and Stepper all render cleanly styled with no PrimeUI license. Co-Authored-By: Claude Opus 4.8 (1M context) --- Source/.storybook/preview.js | 9 +- Source/Display/Badge.tsx | 2 +- Source/Display/Tag.tsx | 2 +- Source/README.md | 23 +++ Source/package.json | 3 +- Source/theme.css | 280 +++++++++++++++++++++++++++++++++++ 6 files changed, 312 insertions(+), 7 deletions(-) create mode 100644 Source/theme.css diff --git a/Source/.storybook/preview.js b/Source/.storybook/preview.js index e253347..a6c30b2 100644 --- a/Source/.storybook/preview.js +++ b/Source/.storybook/preview.js @@ -5,6 +5,7 @@ import { addons } from 'storybook/preview-api'; import React from 'react'; import 'primeicons/primeicons.css'; import './preview.css'; +import '../theme.css'; import Aura from '@primeuix/themes/aura'; import { CratisComponentsProvider } from '../Common/CratisComponentsProvider'; import { tailwindPtPreset } from './pt-preset'; @@ -50,11 +51,11 @@ const STYLING_MODES = { bodyClass: null, providerValue: withLicense({ ripple: true, theme: styledTheme }), }, - 'cratis-themed': { - title: 'Path B — Styled with custom Cratis palette — needs PrimeUI license', + 'cratis-theme': { + title: 'Path B — Cratis baseline theme (no license)', dark: true, - bodyClass: 'cratis-themed', - providerValue: withLicense({ ripple: true, theme: styledTheme }), + bodyClass: 'cratis-theme', + providerValue: { unstyled: true }, }, }; diff --git a/Source/Display/Badge.tsx b/Source/Display/Badge.tsx index e4e3094..3d4d106 100644 --- a/Source/Display/Badge.tsx +++ b/Source/Display/Badge.tsx @@ -28,7 +28,7 @@ export interface BadgeProps { * unread counts, notification indicators, and small numeric overlays. */ export const Badge = ({ value, severity, size, shape, className, children }: BadgeProps) => ( - + {value ?? children} ); diff --git a/Source/Display/Tag.tsx b/Source/Display/Tag.tsx index 8c301b4..115d9e2 100644 --- a/Source/Display/Tag.tsx +++ b/Source/Display/Tag.tsx @@ -29,7 +29,7 @@ export interface TagProps { * or a read-model flag. */ export const Tag = ({ value, severity, rounded, icon, className, children }: TagProps) => ( - + {icon} {value ?? children} diff --git a/Source/README.md b/Source/README.md index 6d8156f..12fc99e 100644 --- a/Source/README.md +++ b/Source/README.md @@ -179,6 +179,29 @@ Omit `theme` entirely to stay unstyled-first — ship only structure plus the `--cratis-*` tokens and bring your own visuals (see the pass-through / `pt` options below). +### Use the Cratis baseline theme (no license) + +Want a polished default look **without a PrimeUI license**? Ship the components +unstyled and import the Cratis baseline theme — a token-based stylesheet that +styles every component from the `--cratis-*` layer: + +```tsx +import 'primeicons/primeicons.css'; +import '@cratis/components/theme'; // the baseline theme + +export const App = () => ( + +
{/* scope: put on , app root, or a subtree */} + +
+
+); +``` + +Add `cratis-dark` to an ancestor for the dark palette. The theme defers to a +`@primeuix/themes` preset's `--p-*` tokens when one is present, so you can layer +it under a preset too, and every rule is overridable via your own CSS or `pt`. + #### Override a single component with CSS Plain CSS works fine on top of the theme. Target either PrimeReact's class diff --git a/Source/package.json b/Source/package.json index 7e15070..8f1b016 100644 --- a/Source/package.json +++ b/Source/package.json @@ -106,7 +106,8 @@ "import": "./dist/esm/types/index.js" }, "./styles": "./dist/esm/tailwind-utilities.css", - "./tokens": "./dist/esm/tokens.css" + "./tokens": "./dist/esm/tokens.css", + "./theme": "./dist/esm/theme.css" }, "scripts": { "prepare": "yarn g:build", diff --git a/Source/theme.css b/Source/theme.css new file mode 100644 index 0000000..128faa2 --- /dev/null +++ b/Source/theme.css @@ -0,0 +1,280 @@ +/* Copyright (c) Cratis. All rights reserved. */ +/* Licensed under the MIT license. See LICENSE file in the project root for full license information. */ + +/* + * Cratis baseline theme — a token-based default look for the unstyled + * PrimeReact 11 components, built entirely on the `--cratis-*` / `--surface-*` + * token layer. It requires **no PrimeUI license** (unlike the styled + * `@primeuix/themes` presets). + * + * Usage: run the components `unstyled`, `import '@cratis/components/theme'`, and + * add `class="cratis-theme"` to an ancestor (e.g. or your app root). The + * rules are scoped under `.cratis-theme` so it can also theme a subtree. + * + * It intentionally styles all buttons as filled primary (the unstyled DOM + * carries no variant/severity); component-owned CSS (ActionMenubar, paginator, + * toast, tooltip, filter menu) refines specific button roles on top. + */ + +/* Concrete palette. The Cratis tokens normally resolve from a `@primeuix/themes` + preset's `--p-*` variables; with no preset (the license-free path) that chain + is empty, so the baseline theme supplies real colors here — deferring to a + preset's `--p-*` when one *is* present, and falling back to these otherwise. */ +.cratis-theme { + color: var(--cratis-text-color); + + --cratis-primary-color: var(--p-primary-color, #3b82f6); + --cratis-primary-color-text: var(--p-primary-contrast-color, #ffffff); + --cratis-primary-300: var(--p-primary-300, #93c5fd); + --cratis-primary-400: var(--p-primary-400, #60a5fa); + --cratis-primary-500: var(--p-primary-500, #3b82f6); + --cratis-primary-600: var(--p-primary-600, #2563eb); + --cratis-green-500: var(--p-green-500, #22c55e); + --cratis-orange-500: var(--p-orange-500, #f59e0b); + --cratis-red-500: var(--p-red-500, #ef4444); + + --cratis-surface-0: var(--p-surface-0, #ffffff); + --cratis-surface-100: var(--p-surface-100, #f1f5f9); + --cratis-surface-section: var(--p-surface-50, #f8fafc); + --cratis-surface-card: var(--p-surface-0, #ffffff); + --cratis-surface-overlay: var(--p-surface-0, #ffffff); + --cratis-surface-hover: var(--p-surface-100, #f1f5f9); + --cratis-surface-border: var(--p-surface-200, #e2e8f0); + + --cratis-text-color: var(--p-text-color, #1e293b); + --cratis-text-color-secondary: var(--p-text-muted-color, #64748b); + --cratis-highlight-bg: var(--p-highlight-background, #dbeafe); + --cratis-highlight-text-color: var(--p-highlight-color, #1e40af); + --cratis-maskbg: rgba(0, 0, 0, 0.4); + --cratis-border-radius: 6px; +} + +/* Dark palette — applied when the `cratis-dark` class is on an ancestor. */ +.cratis-dark .cratis-theme, +.cratis-theme.cratis-dark { + --cratis-surface-0: #1e293b; + --cratis-surface-100: #334155; + --cratis-surface-section: #0f172a; + --cratis-surface-card: #1e293b; + --cratis-surface-overlay: #1e293b; + --cratis-surface-hover: #334155; + --cratis-surface-border: #334155; + + --cratis-text-color: #f8fafc; + --cratis-text-color-secondary: #94a3b8; + --cratis-highlight-bg: #1e3a8a; + --cratis-highlight-text-color: #ffffff; + --cratis-maskbg: rgba(0, 0, 0, 0.6); +} + +/* ── Buttons ─────────────────────────────────────────────────────────────── */ +.cratis-theme [data-scope='button'] { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 0.5rem 1rem; + border: 1px solid transparent; + border-radius: var(--cratis-border-radius, 6px); + background: var(--cratis-primary-color); + color: var(--cratis-primary-color-text); + font-weight: 500; + cursor: pointer; + transition: background-color 0.15s, opacity 0.15s; +} +.cratis-theme [data-scope='button']:not([disabled]):hover { background: var(--cratis-primary-600, var(--cratis-primary-color)); } +.cratis-theme [data-scope='button']:focus-visible { outline: none; box-shadow: 0 0 0 2px var(--cratis-primary-300, var(--cratis-primary-color)); } +.cratis-theme [data-scope='button'][disabled] { opacity: 0.5; cursor: not-allowed; } + +/* ── Text inputs ─────────────────────────────────────────────────────────── */ +.cratis-theme [data-scope='inputtext'], +.cratis-theme [data-scope='textarea'], +.cratis-theme [data-scope='inputnumber'][data-part='input'], +.cratis-theme [data-scope='datepicker'][data-part='input'], +.cratis-theme [data-scope='inputtags'][data-part='control'], +.cratis-theme [data-scope='select'][data-part='root'] { + width: 100%; + padding: 0.5rem 0.75rem; + background: var(--cratis-surface-0); + color: var(--cratis-text-color); + border: 1px solid var(--cratis-surface-border); + border-radius: var(--cratis-border-radius, 6px); + box-sizing: border-box; +} +.cratis-theme [data-scope='textarea'] { resize: vertical; min-height: 4rem; } +.cratis-theme [data-scope='select'][data-part='root'] { + display: inline-flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + cursor: pointer; +} +.cratis-theme [data-scope='select'][data-part='value'] { flex: 1; text-align: left; overflow: hidden; text-overflow: ellipsis; } +.cratis-theme [data-scope='select'][data-part='arrow'], +.cratis-theme [data-scope='datepicker'][data-part='trigger'] { color: var(--cratis-text-color-secondary); } +.cratis-theme [data-scope='inputtext']:focus-visible, +.cratis-theme [data-scope='textarea']:focus-visible, +.cratis-theme [data-scope='inputnumber'][data-part='input']:focus-visible { + outline: none; + border-color: var(--cratis-primary-color); + box-shadow: 0 0 0 2px var(--cratis-primary-300, transparent); +} +.cratis-theme [aria-invalid='true'], +.cratis-theme [data-invalid] > [data-scope='inputtext'], +.cratis-theme [data-scope='inputtext'][aria-invalid='true'] { border-color: var(--cratis-red-500); } + +/* ── Select / datepicker popup ───────────────────────────────────────────── */ +.cratis-theme [data-scope='select'][data-part='list'], +.cratis-theme [data-scope='select'][data-part='popup'] { + background: var(--cratis-surface-overlay); + border: 1px solid var(--cratis-surface-border); + border-radius: var(--cratis-border-radius, 6px); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3); + overflow: hidden; +} +.cratis-theme [data-scope='select'][data-part='option'] { padding: 0.5rem 0.75rem; cursor: pointer; } +.cratis-theme [data-scope='select'][data-part='option']:hover, +.cratis-theme [data-scope='select'][data-part='option'][data-selected] { background: var(--cratis-surface-hover); } + +/* ── Checkbox / radio ────────────────────────────────────────────────────── */ +.cratis-theme [data-scope='checkboxbox'], +.cratis-theme [data-scope='radiobutton'][data-part='box'] { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.15rem; + height: 1.15rem; + background: var(--cratis-surface-0); + border: 1px solid var(--cratis-surface-border); + color: var(--cratis-primary-color-text); +} +.cratis-theme [data-scope='checkboxbox'] { border-radius: 4px; } +.cratis-theme [data-scope='radiobutton'][data-part='box'] { border-radius: 50%; } +.cratis-theme [data-scope='checkbox'][data-part='root'][data-checked] [data-scope='checkboxbox'], +.cratis-theme [data-scope='radiobutton'][data-part='root'][data-checked] [data-part='box'] { + background: var(--cratis-primary-color); + border-color: var(--cratis-primary-color); +} + +/* ── Toggle switch ───────────────────────────────────────────────────────── */ +.cratis-theme [data-scope='toggleswitch'][data-part='control'] { + display: inline-flex; + align-items: center; + width: 2.5rem; + height: 1.4rem; + padding: 0.15rem; + background: var(--cratis-surface-border); + border-radius: 999px; + transition: background-color 0.15s; +} +.cratis-theme [data-scope='toggleswitch'][data-part='root'][data-checked] [data-part='control'] { background: var(--cratis-primary-color); } +.cratis-theme [data-scope='toggleswitch'][data-part='handle'] { + width: 1.1rem; + height: 1.1rem; + background: var(--cratis-surface-0); + border-radius: 50%; + transition: transform 0.15s; +} +.cratis-theme [data-scope='toggleswitch'][data-part='root'][data-checked] [data-part='handle'] { transform: translateX(1.1rem); } + +/* ── Slider ──────────────────────────────────────────────────────────────── */ +.cratis-theme [data-scope='slider'][data-part='track'] { height: 0.35rem; background: var(--cratis-surface-border); border-radius: 999px; } +.cratis-theme [data-scope='slider'][data-part='range'] { height: 0.35rem; background: var(--cratis-primary-color); border-radius: 999px; } +.cratis-theme [data-scope='slider'][data-part='handle'] { width: 1.1rem; height: 1.1rem; background: var(--cratis-primary-color); border-radius: 50%; } + +/* ── Dialog ──────────────────────────────────────────────────────────────── */ +.cratis-theme [data-scope='dialog'][data-part='backdrop'], +.cratis-theme [data-scope='dialog'][data-part='mask'] { background: var(--cratis-maskbg, rgba(0, 0, 0, 0.5)); } +.cratis-theme [data-scope='dialog'][data-part='popup'] { + background: var(--cratis-surface-overlay); + color: var(--cratis-text-color); + border-radius: 0.75rem; + box-shadow: 0 20px 50px rgba(0, 0, 0, 0.45); + overflow: hidden; +} +.cratis-theme [data-scope='dialog'][data-part='header'] { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 1rem 1.25rem; + border-bottom: 1px solid var(--cratis-surface-border); + font-weight: 600; +} +.cratis-theme [data-scope='dialog'][data-part='content'] { padding: 1.25rem; } +.cratis-theme [data-scope='dialog'][data-part='footer'] { padding: 1rem 1.25rem; border-top: 1px solid var(--cratis-surface-border); } +.cratis-theme [data-scope='dialog'][data-part='close'] { + border: none; background: transparent; color: var(--cratis-text-color-secondary); + cursor: pointer; padding: 0.25rem; border-radius: 0.25rem; +} +.cratis-theme [data-scope='dialog'][data-part='close']:hover { color: var(--cratis-text-color); background: var(--cratis-surface-hover); } + +/* ── DataTable ───────────────────────────────────────────────────────────── */ +.cratis-theme [data-scope='datatable'][data-part='table'] { width: 100%; border-collapse: collapse; } +.cratis-theme [data-scope='datatable'][data-part='thead'] { background: var(--cratis-surface-section); } +.cratis-theme [data-scope='datatable'][data-part='theadcell'] { + padding: 0.625rem 0.75rem; text-align: left; font-weight: 600; + color: var(--cratis-text-color-secondary); border-bottom: 1px solid var(--cratis-surface-border); +} +.cratis-theme [data-scope='datatable'][data-part='cell'] { padding: 0.625rem 0.75rem; border-bottom: 1px solid var(--cratis-surface-border); } +.cratis-theme [data-scope='datatable'][data-part='row']:hover { background: var(--cratis-surface-hover); } +.cratis-theme [data-scope='datatable'][data-part='row'][data-selected] { background: var(--cratis-highlight-bg); color: var(--cratis-highlight-text-color); } + +/* ── Tag / Badge ─────────────────────────────────────────────────────────── */ +.cratis-theme [data-scope='tag'] { + display: inline-flex; align-items: center; gap: 0.25rem; + padding: 0.15rem 0.5rem; border-radius: 4px; font-size: 0.8125rem; font-weight: 500; + background: var(--cratis-surface-hover); color: var(--cratis-text-color); +} +.cratis-theme [data-scope='badge'] { + display: inline-flex; align-items: center; justify-content: center; + min-width: 1.4rem; height: 1.4rem; padding: 0 0.4rem; border-radius: 999px; + font-size: 0.75rem; font-weight: 600; background: var(--cratis-primary-color); color: var(--cratis-primary-color-text); +} +.cratis-theme [data-severity='success'] { background: var(--cratis-green-500); color: #fff; } +.cratis-theme [data-severity='info'] { background: var(--cratis-primary-color); color: var(--cratis-primary-color-text); } +.cratis-theme [data-severity='warn'] { background: var(--cratis-orange-500, #f59e0b); color: #fff; } +.cratis-theme [data-severity='danger'] { background: var(--cratis-red-500); color: #fff; } +.cratis-theme [data-severity='secondary'] { background: var(--cratis-surface-hover); color: var(--cratis-text-color); } + +/* ── Chip / Avatar ───────────────────────────────────────────────────────── */ +.cratis-theme [data-scope='chip'][data-part='root'] { + display: inline-flex; align-items: center; gap: 0.375rem; + padding: 0.25rem 0.625rem; border-radius: 999px; background: var(--cratis-surface-hover); color: var(--cratis-text-color); +} +.cratis-theme [data-scope='chip'][data-part='remove'] { cursor: pointer; color: var(--cratis-text-color-secondary); } +.cratis-theme [data-scope='avatar'][data-part='root'] { + display: inline-flex; align-items: center; justify-content: center; + width: 2.25rem; height: 2.25rem; border-radius: 50%; overflow: hidden; + background: var(--cratis-surface-hover); color: var(--cratis-text-color); font-weight: 600; +} +.cratis-theme [data-scope='avatar'][data-part='root'][data-size='large'] { width: 3rem; height: 3rem; } + +/* ── ProgressBar / Skeleton ──────────────────────────────────────────────── */ +.cratis-theme [data-scope='progressbar'][data-part='root'] { + height: 1.25rem; background: var(--cratis-surface-border); border-radius: 999px; overflow: hidden; +} +.cratis-theme [data-scope='progressbar'][data-part='indicator'] { + display: flex; align-items: center; justify-content: center; height: 100%; + background: var(--cratis-primary-color); color: var(--cratis-primary-color-text); font-size: 0.75rem; + transition: width 0.2s; +} +.cratis-theme [data-scope='skeleton'] { + display: block; background: var(--cratis-surface-hover); border-radius: 4px; + animation: cratis-skeleton-pulse 1.4s ease-in-out infinite; +} +@keyframes cratis-skeleton-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } } + +/* ── Stepper ─────────────────────────────────────────────────────────────── */ +.cratis-theme [data-scope='stepper'][data-part='number'] { + display: inline-flex; align-items: center; justify-content: center; + width: 1.75rem; height: 1.75rem; border-radius: 50%; + background: var(--cratis-surface-hover); color: var(--cratis-text-color); font-weight: 600; +} +.cratis-theme [data-scope='stepper'][data-part='step'][data-active] [data-part='number'] { + background: var(--cratis-primary-color); color: var(--cratis-primary-color-text); +} +.cratis-theme [data-scope='stepper'][data-part='separator'] { background: var(--cratis-surface-border); } +.cratis-theme [data-scope='stepper'][data-part='header'] { + display: inline-flex; align-items: center; gap: 0.5rem; background: transparent; border: none; cursor: pointer; color: inherit; +} From a9094afd7e9d9fea61c35229f38daeb9b1159f64 Mon Sep 17 00:00:00 2001 From: woksin Date: Thu, 16 Jul 2026 17:01:13 +0200 Subject: [PATCH 19/42] Document the new component surface in the .ai rules Teach consumers and AI about the surface added in this migration: - components.md: Notifications (Toaster/toast/toastCommandResult) and Display (Tag/Badge/Chip/Skeleton/Avatar/ProgressBar) subpaths, the new CommandForm fields, and column filtering / global search. - react.md: point the out-of-dialog command branch at toastCommandResult. Co-Authored-By: Claude Opus 4.8 (1M context) --- .ai/rules/components.md | 27 ++++++++++++++++++++++++++- .ai/rules/react.md | 2 ++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/.ai/rules/components.md b/.ai/rules/components.md index 79139a8..14a3000 100644 --- a/.ai/rules/components.md +++ b/.ai/rules/components.md @@ -21,11 +21,36 @@ Reach PrimeReact almost exclusively through Cratis Components wrappers. Import f | Dropdown | `Dropdown` | `@cratis/components/Dropdown` | | Command dialog | `CommandDialog` / `StepperCommandDialog` | `@cratis/components/CommandDialog` | | Data/confirmation dialog | `Dialog` / `ConfirmationDialog` / `BusyIndicatorDialog` | `@cratis/components/Dialogs` | -| Command form fields | `InputTextField`, … | `@cratis/components/CommandForm` | +| Command form fields | `InputTextField`, `PasswordField`, `ToggleSwitchField`, `RatingField`, … | `@cratis/components/CommandForm` | +| Notifications (toasts) | `Toaster` / `toast` / `toastCommandResult` | `@cratis/components/Notifications` | +| Status & display | `Tag` / `Badge` / `Chip` / `Skeleton` / `Avatar` / `ProgressBar` | `@cratis/components/Display` | | Canvas tool palette | `Toolbar` | `@cratis/components/Toolbar` | Use `Dropdown` from `@cratis/components/Dropdown` (not raw `primereact/dropdown`) — it appends to the document body and stacks correctly above overlays, avoiding the z-index issues raw PrimeReact dropdowns have inside dialogs. +### Notifications — feedback for commands run outside a dialog + +`CommandDialog` handles success/error feedback itself. For a command executed +**programmatically** (`command.execute()` outside a dialog), mount one +`` near the app root and surface the result with `toastCommandResult` +(both from `@cratis/components/Notifications`) — it maps the granular +`ICommandResult` flags to the right toast (success, not-authorized, validation +with per-field messages, exceptions — never stack traces): + +```tsx +const result = await command.execute(); +if (toastCommandResult(result, { successTitle: 'Author registered' })) refresh(); +``` + +For ad-hoc notifications, call the imperative `toast.success/info/warn/error(...)`. + +### Column filtering & display components + +`` supports `filter` (a per-column filter menu with match modes) and +`DataPage` / the data tables show a global search box when `globalFilterFields` +is set. Use the `Display` components (`Tag`, `Badge`, `Skeleton`, …) for status +indicators and loading states in tables and detail views. + ### `DataPage` — query list pages `DataPage` (from `@cratis/components/DataPage`) owns the data table's subscription, paging, selection, action menubar, and details split — **do not pre-fetch rows and pass an `items` array**. Required props: `title`, `query` (`Constructor`; snapshot and observable queries are auto-detected), `emptyMessage`, and `children`. Other props: `queryArguments`, `dataKey` (pass whenever the read model has an identity), `selection` / `onSelectionChange`, `globalFilterFields` / `defaultFilters` / `clientFiltering`, `detailsComponent` (`React.FC>` = `{ item, onRefresh? }`), `onRefresh`, and PrimeReact pass-through `tablePt`/`tableClassName`/`menubarPt`/`menubarClassName`. diff --git a/.ai/rules/react.md b/.ai/rules/react.md index 9574aa8..915c8d1 100644 --- a/.ai/rules/react.md +++ b/.ai/rules/react.md @@ -153,6 +153,8 @@ if (result.hasExceptions) { toast.error('Something went wrong'); console.error(r // happy path — refresh queries, close, etc. ``` +`toastCommandResult(result, opts)` from `@cratis/components/Notifications` collapses this whole branch into one call (with a `` mounted) — success/not-authorized/validation/exception → the right toast, no stack traces shown. + ### Command helpers - **`useCommandInstance(Command)`** — read the live reactive command instance for dependent fields (read it; never mutate — mutations go through field bindings). From 605579f38cb7b2199d5815ae103cf1f768086025 Mon Sep 17 00:00:00 2001 From: woksin Date: Thu, 16 Jul 2026 17:12:30 +0200 Subject: [PATCH 20/42] Accessibility fixes and provider Toaster auto-mount MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ran axe against the new components/theme and fixed what's in our control: - Use WCAG-AA-safe (darker) filled backgrounds for buttons, badges, severity tags, and the progress bar in the baseline theme, and lift the dark-mode secondary text — clearing every color-contrast violation on the toaster, Display, and DataPage stories. - Give the Chip remove control a role so its aria-label is valid. - Strip the invalid aria-sort PrimeReact 11 emits on the data-table sort button (aria-sort belongs on the column header, not a role=button), clearing that critical violation. - Add a navigation landmark to the table paginator. DX: `CratisComponentsProvider` gains an optional `toaster` prop that mounts a `` so `toast(...)` works app-wide with no extra setup. Known upstream: PrimeReact 11's Stepper marks its headers role="tab" and points aria-controls at conditionally-rendered panels; that ARIA model is the framework's and not fully axe-clean, though keyboard navigation works. Co-Authored-By: Claude Opus 4.8 (1M context) --- Source/Common/CratisComponentsProvider.tsx | 17 +++++++++++++-- Source/DataTables/DataTableCore.tsx | 4 +++- Source/DataTables/TablePaginator.tsx | 5 ++++- Source/Display/Chip.tsx | 2 +- Source/theme.css | 24 ++++++++++++---------- 5 files changed, 36 insertions(+), 16 deletions(-) diff --git a/Source/Common/CratisComponentsProvider.tsx b/Source/Common/CratisComponentsProvider.tsx index e000b44..3176681 100644 --- a/Source/Common/CratisComponentsProvider.tsx +++ b/Source/Common/CratisComponentsProvider.tsx @@ -5,6 +5,7 @@ import React, { useMemo } from 'react'; import { PrimeReactProvider } from '@primereact/core'; import type { PrimeReactProps } from '@primereact/types/core'; import { merge } from 'ts-deepmerge'; +import { Toaster, type ToasterProps } from '../Notifications'; /** * Configuration accepted by {@link CratisComponentsProvider}. Mirrors PrimeReact 11's @@ -21,6 +22,13 @@ export interface CratisComponentsProviderProps { */ value?: CratisComponentsConfig; + /** + * When set, mounts a {@link Toaster} inside the provider so the imperative + * `toast(...)` works app-wide with no extra setup. Pass `true` for the + * defaults, or a {@link ToasterProps} object to position/configure it. + */ + toaster?: boolean | ToasterProps; + children: React.ReactNode; } @@ -66,8 +74,13 @@ export const mergeCratisComponentsConfig = (value: CratisComponentsConfig | unde * {@link PrimeReactProvider} themselves — this component is an optional convenience, * not a requirement. */ -export const CratisComponentsProvider = ({ value, children }: CratisComponentsProviderProps) => { +export const CratisComponentsProvider = ({ value, toaster, children }: CratisComponentsProviderProps) => { const merged = useMemo(() => mergeCratisComponentsConfig(value), [value]); - return {children}; + return ( + + {children} + {toaster && } + + ); }; diff --git a/Source/DataTables/DataTableCore.tsx b/Source/DataTables/DataTableCore.tsx index d4b709d..b833d5d 100644 --- a/Source/DataTables/DataTableCore.tsx +++ b/Source/DataTables/DataTableCore.tsx @@ -192,7 +192,9 @@ export const DataTableCore = ({ className={column.props.headerClassName}>
{column.props.sortable && column.props.field ? ( - + // aria-sort belongs on the column header, not the sort button; PrimeReact 11's + // Sort part puts it on its role="button" element (invalid ARIA), so strip it here. + {column.props.header} diff --git a/Source/DataTables/TablePaginator.tsx b/Source/DataTables/TablePaginator.tsx index 65a0ba2..793f91e 100644 --- a/Source/DataTables/TablePaginator.tsx +++ b/Source/DataTables/TablePaginator.tsx @@ -34,7 +34,10 @@ export const TablePaginator = ({ page, pageCount, onPageChange, totalItems, page const rangeReport = paginatorRange(page, pageSize, totalItems); return ( -
+
{rangeReport && {rangeReport}}
diff --git a/Source/CommandForm/fields/ToggleSwitchField.tsx b/Source/CommandForm/fields/ToggleSwitchField.tsx index be11987..613cc0a 100644 --- a/Source/CommandForm/fields/ToggleSwitchField.tsx +++ b/Source/CommandForm/fields/ToggleSwitchField.tsx @@ -34,14 +34,12 @@ export const ToggleSwitchField = asCommandFormField wrapping the switch: the underlying checkbox input is a // descendant, so the visible text becomes its accessible name (implicit // association) regardless of the composition's internal structure — and - // the text doubles the click target. `aria-label` covers the label-less - // case so the switch is never nameless to a screen reader. + // the text doubles the click target.