From cf5f8b36245c0643cc0df01cd856441ffb4d43a9 Mon Sep 17 00:00:00 2001 From: Luke Bennett Date: Sun, 3 May 2026 21:12:39 +1000 Subject: [PATCH] Refactor code structure (#28) --- .oxlintrc.json | 17 + package.json | 1 + packages/@luke-ui/react/AGENTS.md | 1 + packages/@luke-ui/react/package.json | 3 - packages/@luke-ui/react/src/button/index.tsx | 2 +- .../@luke-ui/react/src/button/primitive.tsx | 2 +- .../react/src/combobox-field/index.tsx | 35 +- .../@luke-ui/react/src/combobox/control.tsx | 34 ++ .../react/src/combobox/empty-state.tsx | 14 + .../@luke-ui/react/src/combobox/index.tsx | 330 +-------------- .../@luke-ui/react/src/combobox/input.tsx | 45 ++ packages/@luke-ui/react/src/combobox/item.tsx | 51 +++ .../@luke-ui/react/src/combobox/listbox.tsx | 52 +++ .../@luke-ui/react/src/combobox/popover.tsx | 22 + packages/@luke-ui/react/src/combobox/root.tsx | 55 +++ .../@luke-ui/react/src/combobox/section.tsx | 38 ++ .../@luke-ui/react/src/combobox/trigger.tsx | 33 ++ packages/@luke-ui/react/src/emoji/index.tsx | 2 +- packages/@luke-ui/react/src/field.docs.mdx | 2 +- .../index.tsx => field/description.tsx} | 0 .../index.tsx => field/error.tsx} | 0 packages/@luke-ui/react/src/field/index.tsx | 16 +- .../index.tsx => field/label.tsx} | 0 packages/@luke-ui/react/src/heading/index.tsx | 2 +- packages/@luke-ui/react/src/icon/index.tsx | 4 +- packages/@luke-ui/react/src/lib/button.ts | 13 - .../react/src/loading-spinner/index.tsx | 6 +- .../@luke-ui/react/src/recipes/layers.css.ts | 10 + .../react/src/recipes/loading-spinner.css.ts | 3 +- .../@luke-ui/react/src/recipes/text.css.ts | 7 +- .../react/src/sizing/button-sizing.ts | 13 + .../{lib/icon.ts => sizing/icon-sizing.ts} | 3 +- .../{index.ts => create-variants.ts} | 0 .../@luke-ui/react/src/styles/composed.css.ts | 1 - .../@luke-ui/react/src/styles/index.css.ts | 1 - .../react/src/styles/layered-style.css.ts | 2 +- .../react/src/styles/primitives.css.ts | 11 +- .../@luke-ui/react/src/styles/vars.css.ts | 8 +- .../@luke-ui/react/src/text-field/index.tsx | 4 +- .../@luke-ui/react/src/tokens/converters.ts | 50 +++ packages/@luke-ui/react/src/tokens/groups.ts | 24 ++ packages/@luke-ui/react/src/tokens/index.ts | 219 ++-------- .../react/src/tokens/media-queries.ts | 12 + packages/@luke-ui/react/src/tokens/values.ts | 104 +++++ .../types/{index.ts => distributive-omit.ts} | 0 .../{index.ts => get-typography-class.ts} | 3 +- packages/turbo-generators/config.ts | 175 +++++--- .../templates/component/component.tsx.hbs | 2 +- packages/turbo-generators/tsconfig.json | 18 + pnpm-lock.yaml | 394 ++++++++++++++++++ 50 files changed, 1221 insertions(+), 623 deletions(-) create mode 100644 packages/@luke-ui/react/src/combobox/control.tsx create mode 100644 packages/@luke-ui/react/src/combobox/empty-state.tsx create mode 100644 packages/@luke-ui/react/src/combobox/input.tsx create mode 100644 packages/@luke-ui/react/src/combobox/item.tsx create mode 100644 packages/@luke-ui/react/src/combobox/listbox.tsx create mode 100644 packages/@luke-ui/react/src/combobox/popover.tsx create mode 100644 packages/@luke-ui/react/src/combobox/root.tsx create mode 100644 packages/@luke-ui/react/src/combobox/section.tsx create mode 100644 packages/@luke-ui/react/src/combobox/trigger.tsx rename packages/@luke-ui/react/src/{field-description/index.tsx => field/description.tsx} (100%) rename packages/@luke-ui/react/src/{field-error/index.tsx => field/error.tsx} (100%) rename packages/@luke-ui/react/src/{field-label/index.tsx => field/label.tsx} (100%) delete mode 100644 packages/@luke-ui/react/src/lib/button.ts create mode 100644 packages/@luke-ui/react/src/recipes/layers.css.ts create mode 100644 packages/@luke-ui/react/src/sizing/button-sizing.ts rename packages/@luke-ui/react/src/{lib/icon.ts => sizing/icon-sizing.ts} (88%) rename packages/@luke-ui/react/src/style-helpers/{index.ts => create-variants.ts} (100%) delete mode 100644 packages/@luke-ui/react/src/styles/composed.css.ts create mode 100644 packages/@luke-ui/react/src/tokens/converters.ts create mode 100644 packages/@luke-ui/react/src/tokens/groups.ts create mode 100644 packages/@luke-ui/react/src/tokens/media-queries.ts create mode 100644 packages/@luke-ui/react/src/tokens/values.ts rename packages/@luke-ui/react/src/types/{index.ts => distributive-omit.ts} (100%) rename packages/@luke-ui/react/src/typography/{index.ts => get-typography-class.ts} (85%) create mode 100644 packages/turbo-generators/tsconfig.json diff --git a/.oxlintrc.json b/.oxlintrc.json index 01e9419d..cfe7ee94 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -9,6 +9,7 @@ "**/routeTree.gen.ts" ], "plugins": ["import", "jsx-a11y", "react", "typescript", "vitest"], + "jsPlugins": [{ "name": "react-hooks-js", "specifier": "eslint-plugin-react-hooks" }], "rules": { "@typescript-eslint/array-type": ["error", { "default": "generic" }], "@typescript-eslint/consistent-type-exports": [ @@ -29,6 +30,22 @@ "no-console": "warn", "no-unused-vars": "error", "react/no-array-index-key": "off", + "react/exhaustive-deps": "warn", + "react/rules-of-hooks": "error", + "react-hooks-js/config": "error", + "react-hooks-js/error-boundaries": "error", + "react-hooks-js/gating": "error", + "react-hooks-js/globals": "error", + "react-hooks-js/immutability": "error", + "react-hooks-js/preserve-manual-memoization": "error", + "react-hooks-js/purity": "error", + "react-hooks-js/refs": "error", + "react-hooks-js/set-state-in-effect": "error", + "react-hooks-js/set-state-in-render": "error", + "react-hooks-js/static-components": "error", + "react-hooks-js/unsupported-syntax": "warn", + "react-hooks-js/use-memo": "error", + "react-hooks-js/incompatible-library": "warn", "sort-imports": "off" } } diff --git a/package.json b/package.json index 2425677e..362c7ca3 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "devDependencies": { "@changesets/cli": "^2.31.0", "@turbo/gen": "^2.9.6", + "eslint-plugin-react-hooks": "^7.1.1", "knip": "^6.6.3", "oxfmt": "^0.46.0", "oxlint": "^1.61.0", diff --git a/packages/@luke-ui/react/AGENTS.md b/packages/@luke-ui/react/AGENTS.md index 95e46f36..fdaf0040 100644 --- a/packages/@luke-ui/react/AGENTS.md +++ b/packages/@luke-ui/react/AGENTS.md @@ -3,3 +3,4 @@ - Do not hand-edit `.generated/entries.ts` or `package.json` exports; entries are generated, tsdown updates exports at build. - When adding a component, use `pnpm generate:component` from repo root (not manual file creation) so the group barrel, styles index, and docs are updated correctly. - Stories (`*.stories.tsx`) are the tests; there are no separate `*.test.tsx` files. +- React Compiler is enabled — do not use `useCallback` or `useMemo`; the compiler auto-memoizes. diff --git a/packages/@luke-ui/react/package.json b/packages/@luke-ui/react/package.json index 6d9fb4e7..9ce683af 100644 --- a/packages/@luke-ui/react/package.json +++ b/packages/@luke-ui/react/package.json @@ -15,9 +15,6 @@ "./combobox-field": "./dist/combobox-field/index.js", "./emoji": "./dist/emoji/index.js", "./field": "./dist/field/index.js", - "./field-description": "./dist/field-description/index.js", - "./field-error": "./dist/field-error/index.js", - "./field-label": "./dist/field-label/index.js", "./field/primitive": "./dist/field/primitive.js", "./heading": "./dist/heading/index.js", "./heading-context": "./dist/heading-context/index.js", diff --git a/packages/@luke-ui/react/src/button/index.tsx b/packages/@luke-ui/react/src/button/index.tsx index e3396f0c..6fe4e61e 100644 --- a/packages/@luke-ui/react/src/button/index.tsx +++ b/packages/@luke-ui/react/src/button/index.tsx @@ -1,10 +1,10 @@ import type { JSX } from 'react'; import type { ButtonProps as PrimitiveButtonProps } from '../button/primitive.js'; import { Button as PrimitiveButton } from '../button/primitive.js'; -import { BUTTON_FONT_SIZE, BUTTON_ICON_SIZE } from '../lib/button.js'; import { LoadingSpinner } from '../loading-spinner/index.js'; import * as styles from '../recipes/button-composed.css.js'; import type * as primitiveStyles from '../recipes/button.css.js'; +import { BUTTON_FONT_SIZE, BUTTON_ICON_SIZE } from '../sizing/button-sizing.js'; import { Text } from '../text/index.js'; interface ComposedButtonVariantProps extends NonNullable {} diff --git a/packages/@luke-ui/react/src/button/primitive.tsx b/packages/@luke-ui/react/src/button/primitive.tsx index 36312db1..2b5e1e17 100644 --- a/packages/@luke-ui/react/src/button/primitive.tsx +++ b/packages/@luke-ui/react/src/button/primitive.tsx @@ -3,8 +3,8 @@ import type { ButtonProps as RacButtonProps } from 'react-aria-components/Button import { Button as RacButton } from 'react-aria-components/Button'; import { composeRenderProps } from 'react-aria-components/composeRenderProps'; import { IconSizeProvider } from '../icon-size-context/index.js'; -import { BUTTON_ICON_SIZE } from '../lib/button.js'; import * as styles from '../recipes/button.css.js'; +import { BUTTON_ICON_SIZE } from '../sizing/button-sizing.js'; import { cx } from '../utils/index.js'; interface ButtonVariantProps extends NonNullable {} diff --git a/packages/@luke-ui/react/src/combobox-field/index.tsx b/packages/@luke-ui/react/src/combobox-field/index.tsx index 996f2101..ab9420a7 100644 --- a/packages/@luke-ui/react/src/combobox-field/index.tsx +++ b/packages/@luke-ui/react/src/combobox-field/index.tsx @@ -1,27 +1,22 @@ import type { CSSProperties, JSX, ReactNode } from 'react'; -import type { - ComboboxInputProps, - ComboboxListBoxProps, - ComboboxLoadMoreItemProps, - ComboboxPopoverProps, - ComboboxSize, -} from '../combobox/index.js'; -import { - ComboboxControl, - ComboboxEmptyState, - ComboboxInput, - ComboboxListBox, - ComboboxLoadMoreItem, - ComboboxPopover, - ComboboxTextInput, - ComboboxTrigger, -} from '../combobox/index.js'; -import type { FieldErrorProps } from '../field-error/index.js'; -import type { FieldNecessityIndicator } from '../field-label/index.js'; +import { ComboboxControl } from '../combobox/control.js'; +import { ComboboxEmptyState } from '../combobox/empty-state.js'; +import { ComboboxTextInput } from '../combobox/input.js'; +import { ComboboxLoadMoreItem } from '../combobox/item.js'; +import type { ComboboxLoadMoreItemProps } from '../combobox/item.js'; +import { ComboboxListBox } from '../combobox/listbox.js'; +import type { ComboboxListBoxProps } from '../combobox/listbox.js'; +import { ComboboxPopover } from '../combobox/popover.js'; +import type { ComboboxPopoverProps } from '../combobox/popover.js'; +import { ComboboxInput } from '../combobox/root.js'; +import type { ComboboxInputProps, ComboboxSize } from '../combobox/root.js'; +import { ComboboxTrigger } from '../combobox/trigger.js'; +import type { FieldErrorProps } from '../field/error.js'; import { Field } from '../field/index.js'; +import type { FieldNecessityIndicator } from '../field/label.js'; import { Icon } from '../icon/index.js'; import { LoadingSpinner } from '../loading-spinner/index.js'; -import type { DistributiveOmit } from '../types/index.js'; +import type { DistributiveOmit } from '../types/distributive-omit.js'; type ComboboxLoadingState = 'error' | 'filtering' | 'idle' | 'loading' | 'loadingMore' | 'sorting'; diff --git a/packages/@luke-ui/react/src/combobox/control.tsx b/packages/@luke-ui/react/src/combobox/control.tsx new file mode 100644 index 00000000..2387776a --- /dev/null +++ b/packages/@luke-ui/react/src/combobox/control.tsx @@ -0,0 +1,34 @@ +import type { JSX } from 'react'; +import type { GroupProps as RacGroupProps } from 'react-aria-components/Group'; +import { Group as RacGroup } from 'react-aria-components/Group'; +import { composeRenderProps } from 'react-aria-components/composeRenderProps'; +import * as styles from '../recipes/combobox.css.js'; +import type { DistributiveOmit } from '../types/distributive-omit.js'; +import { cx } from '../utils/index.js'; + +interface ComboboxVariantProps extends NonNullable {} + +interface ComboboxStyleProps { + /** Control size. */ + size?: ComboboxVariantProps['size']; +} + +/** Props for the styled combobox control group. */ +export interface ComboboxControlProps + extends DistributiveOmit, ComboboxStyleProps { + className?: RacGroupProps['className']; +} + +/** Control wrapper for combobox text input + trigger content. */ +export function ComboboxControl(props: ComboboxControlProps): JSX.Element { + const { size = 'medium', ...groupProps } = props; + + return ( + { + return cx(styles.comboboxControl({ size }), className); + })} + /> + ); +} diff --git a/packages/@luke-ui/react/src/combobox/empty-state.tsx b/packages/@luke-ui/react/src/combobox/empty-state.tsx new file mode 100644 index 00000000..ba93e493 --- /dev/null +++ b/packages/@luke-ui/react/src/combobox/empty-state.tsx @@ -0,0 +1,14 @@ +import type { JSX, ReactNode } from 'react'; +import * as styles from '../recipes/combobox.css.js'; +import { cx } from '../utils/index.js'; + +export interface ComboboxEmptyStateProps { + children: ReactNode; + className?: string; +} + +export function ComboboxEmptyState(props: ComboboxEmptyStateProps): JSX.Element { + const { children, className } = props; + + return
{children}
; +} diff --git a/packages/@luke-ui/react/src/combobox/index.tsx b/packages/@luke-ui/react/src/combobox/index.tsx index 64e8746b..fbc7b156 100644 --- a/packages/@luke-ui/react/src/combobox/index.tsx +++ b/packages/@luke-ui/react/src/combobox/index.tsx @@ -1,312 +1,18 @@ -import type { JSX, ReactNode } from 'react'; -import { useCallback, useContext } from 'react'; -import { Collection } from 'react-aria-components/Collection'; -import type { - ButtonProps as RacButtonProps, - ComboBoxProps as RacComboBoxProps, - InputProps as RacInputProps, - Key, - ListBoxItemProps as RacListBoxItemProps, - ListBoxLoadMoreItemProps as RacListBoxLoadMoreItemProps, - ListBoxProps as RacListBoxProps, - ListBoxSectionProps as RacListBoxSectionProps, - PopoverProps as RacPopoverProps, -} from 'react-aria-components/ComboBox'; -import { - ComboBoxStateContext, - Button as RacButton, - ComboBox as RacComboBox, - Input as RacInput, - ListBox as RacListBox, - ListBoxItem as RacListBoxItem, - ListBoxLoadMoreItem as RacListBoxLoadMoreItem, - ListBoxSection as RacListBoxSection, - Popover as RacPopover, -} from 'react-aria-components/ComboBox'; -import type { GroupProps as RacGroupProps } from 'react-aria-components/Group'; -import { Group as RacGroup } from 'react-aria-components/Group'; -import { Header as RacHeader } from 'react-aria-components/Header'; -import { ListBoxContext } from 'react-aria-components/ListBox'; -import { composeRenderProps } from 'react-aria-components/composeRenderProps'; -import { useSlottedContext } from 'react-aria-components/slots'; -import * as styles from '../recipes/combobox.css.js'; -import { themeRootClassName } from '../theme/index.js'; -import type { DistributiveOmit } from '../types/index.js'; -import { cx } from '../utils/index.js'; - -interface ComboboxVariantProps extends NonNullable {} - -interface ComboboxStyleProps { - /** Control size. */ - size?: ComboboxVariantProps['size']; -} - -/** Allowed `size` values for Combobox primitives. */ -export type ComboboxSize = NonNullable; - -/** Props for the `ComboboxInput` combobox root. */ -export interface ComboboxInputProps extends DistributiveOmit< - RacComboBoxProps, - | 'defaultSelectedKey' - | 'defaultValue' - | 'onChange' - | 'onOpenChange' - | 'onSelectionChange' - | 'selectedKey' - | 'selectionMode' - | 'value' -> { - /** The currently selected key (controlled). Pass `null` for no selection. */ - value?: Key | null; - - /** The initially selected key (uncontrolled). */ - defaultValue?: Key | null; - - /** Called when the selected value changes. */ - onChange?: (value: Key | null) => void; - - /** Called when the open state changes. */ - onOpenChange?: (isOpen: boolean) => void; - - /** - * The interaction required to display the ComboBox menu. - * @default 'focus' - */ - menuTrigger?: 'focus' | 'input' | 'manual'; -} - -/** Spectrum-style combobox root primitive (single-select only). */ -export function ComboboxInput(props: ComboboxInputProps): JSX.Element { - const { className, menuTrigger = 'focus', ...comboboxProps } = props; - - return ( - { - return cx(styles.comboboxRoot, renderedClassName); - })} - /> - ); -} - -/** Props for the styled combobox control group. */ -export interface ComboboxControlProps - extends DistributiveOmit, ComboboxStyleProps { - className?: RacGroupProps['className']; -} - -/** Control wrapper for combobox text input + trigger content. */ -export function ComboboxControl(props: ComboboxControlProps): JSX.Element { - const { size = 'medium', ...groupProps } = props; - - return ( - { - return cx(styles.comboboxControl({ size }), className); - })} - /> - ); -} - -/** Props for the styled combobox text input. */ -export interface ComboboxTextInputProps - extends - DistributiveOmit, - ComboboxStyleProps { - className?: RacInputProps['className']; -} - -/** Text input used within `ComboboxControl` for combobox behavior. */ -export function ComboboxTextInput(props: ComboboxTextInputProps): JSX.Element { - const { onClick, size = 'medium', ...inputProps } = props; - const state = useContext(ComboBoxStateContext); - - const handleClick = useCallback( - (event: React.MouseEvent) => { - onClick?.(event); - if (!state?.isOpen) { - state?.open(); - } - }, - [onClick, state], - ); - - return ( - { - return cx(styles.comboboxTextInput({ size }), className); - })} - /> - ); -} - -/** Props for the combobox trigger button. */ -export interface ComboboxTriggerProps - extends DistributiveOmit, ComboboxStyleProps { - className?: RacButtonProps['className']; -} - -/** Trigger button used by combobox pattern. */ -export function ComboboxTrigger(props: ComboboxTriggerProps): JSX.Element { - const { size = 'medium', ...buttonProps } = props; - - return ( - { - return cx(styles.comboboxTrigger({ size }), className); - })} - /> - ); -} - -/** Props for the styled combobox popover. */ -export interface ComboboxPopoverProps extends DistributiveOmit< - RacPopoverProps, - 'UNSTABLE_portalContainer' -> {} - -/** Popover surface used for listbox content. */ -export function ComboboxPopover(props: ComboboxPopoverProps): JSX.Element { - return ( - { - return cx(themeRootClassName, styles.comboboxPopover(), className); - })} - /> - ); -} - -/** Props for the styled listbox. */ -export interface ComboboxListBoxProps extends DistributiveOmit< - RacListBoxProps, - 'dependencies' | 'items' -> { - /** Item content for the listbox (render prop or static children). */ - children?: RacListBoxProps['children']; - /** Values that should invalidate the dynamic item cache. */ - dependencies?: ReadonlyArray; - /** Dynamic items rendered by the `children` render prop. */ - items?: Iterable; - /** Optional content appended after the main collection, e.g. a load-more sentinel. */ - loadMoreItem?: ReactNode; -} - -/** Styled listbox for combobox options. */ -export function ComboboxListBox(props: ComboboxListBoxProps): JSX.Element { - const { children, dependencies, items, loadMoreItem, ...listBoxProps } = props; - const listBoxContext = useSlottedContext(ListBoxContext); - const collectionItems = items ?? listBoxContext?.items; - const listBoxChildren = - typeof children === 'function' ? ( - dependencies={dependencies} items={collectionItems}> - {children} - - ) : ( - children - ); - - return ( - { - return cx(styles.comboboxListBox(), className); - })} - {...listBoxProps} - > - {listBoxChildren} - {loadMoreItem} - - ); -} - -/** Props for the combobox load-more sentinel row. */ -export interface ComboboxLoadMoreItemProps - extends DistributiveOmit, ComboboxStyleProps { - className?: RacListBoxLoadMoreItemProps['className']; -} - -/** Styled load-more sentinel for async combobox collections. */ -export function ComboboxLoadMoreItem(props: ComboboxLoadMoreItemProps): JSX.Element { - const { size = 'medium', ...loadMoreItemProps } = props; - - return ( - - ); -} - -/** Props for a styled combobox option item. */ -export interface ComboboxItemProps - extends DistributiveOmit, 'className'>, ComboboxStyleProps { - className?: RacListBoxItemProps['className']; -} - -/** Styled listbox option item. */ -export function ComboboxItem(props: ComboboxItemProps): JSX.Element { - const { size = 'medium', ...itemProps } = props; - - return ( - { - return cx(styles.comboboxItem({ size }), className); - })} - /> - ); -} - -/** Props for a styled section in the combobox listbox. */ -export interface ComboboxSectionProps extends DistributiveOmit< - RacListBoxSectionProps, - 'className' -> { - className?: RacListBoxSectionProps['className']; - /** Optional heading rendered above section items. */ - title?: ReactNode; -} - -/** Styled section wrapper for grouped options. */ -export function ComboboxSection(props: ComboboxSectionProps): JSX.Element { - const { children, className, title, ...sectionProps } = props; - - const sectionClassName = cx(styles.comboboxSection(), className); - - if (typeof children === 'function') { - return ( - - {children} - - ); - } - - return ( - - {title != null ? ( - {title} - ) : null} - {children} - - ); -} - -/** Props for the combobox empty state container. */ -export interface ComboboxEmptyStateProps { - /** Content rendered inside the empty state container. */ - children: ReactNode; - /** Additional CSS class name. */ - className?: string; -} - -/** Styled container for combobox empty and loading states. */ -export function ComboboxEmptyState(props: ComboboxEmptyStateProps): JSX.Element { - const { children, className } = props; - - return
{children}
; -} +export { ComboboxControl } from './control.js'; +export type { ComboboxControlProps } from './control.js'; +export { ComboboxEmptyState } from './empty-state.js'; +export type { ComboboxEmptyStateProps } from './empty-state.js'; +export { ComboboxInput } from './root.js'; +export type { ComboboxInputProps, ComboboxSize } from './root.js'; +export { ComboboxItem, ComboboxLoadMoreItem } from './item.js'; +export type { ComboboxItemProps, ComboboxLoadMoreItemProps } from './item.js'; +export { ComboboxListBox } from './listbox.js'; +export type { ComboboxListBoxProps } from './listbox.js'; +export { ComboboxPopover } from './popover.js'; +export type { ComboboxPopoverProps } from './popover.js'; +export { ComboboxSection } from './section.js'; +export type { ComboboxSectionProps } from './section.js'; +export { ComboboxTextInput } from './input.js'; +export type { ComboboxTextInputProps } from './input.js'; +export { ComboboxTrigger } from './trigger.js'; +export type { ComboboxTriggerProps } from './trigger.js'; diff --git a/packages/@luke-ui/react/src/combobox/input.tsx b/packages/@luke-ui/react/src/combobox/input.tsx new file mode 100644 index 00000000..ee906bd2 --- /dev/null +++ b/packages/@luke-ui/react/src/combobox/input.tsx @@ -0,0 +1,45 @@ +import type { JSX } from 'react'; +import { useContext } from 'react'; +import type { InputProps as RacInputProps } from 'react-aria-components/ComboBox'; +import { ComboBoxStateContext, Input as RacInput } from 'react-aria-components/ComboBox'; +import { composeRenderProps } from 'react-aria-components/composeRenderProps'; +import * as styles from '../recipes/combobox.css.js'; +import type { DistributiveOmit } from '../types/distributive-omit.js'; +import { cx } from '../utils/index.js'; + +interface ComboboxVariantProps extends NonNullable {} + +interface ComboboxStyleProps { + size?: ComboboxVariantProps['size']; +} + +/** Props for the styled combobox text input. */ +export interface ComboboxTextInputProps + extends + DistributiveOmit, + ComboboxStyleProps { + className?: RacInputProps['className']; +} + +/** Text input used within `ComboboxControl` for combobox behavior. */ +export function ComboboxTextInput(props: ComboboxTextInputProps): JSX.Element { + const { onClick, size = 'medium', ...inputProps } = props; + const state = useContext(ComboBoxStateContext); + + const handleClick = (event: React.MouseEvent) => { + onClick?.(event); + if (!state?.isOpen) { + state?.open(); + } + }; + + return ( + { + return cx(styles.comboboxTextInput({ size }), className); + })} + /> + ); +} diff --git a/packages/@luke-ui/react/src/combobox/item.tsx b/packages/@luke-ui/react/src/combobox/item.tsx new file mode 100644 index 00000000..e0d3fa8f --- /dev/null +++ b/packages/@luke-ui/react/src/combobox/item.tsx @@ -0,0 +1,51 @@ +import type { JSX } from 'react'; +import type { + ListBoxItemProps as RacListBoxItemProps, + ListBoxLoadMoreItemProps as RacListBoxLoadMoreItemProps, +} from 'react-aria-components/ComboBox'; +import { + ListBoxItem as RacListBoxItem, + ListBoxLoadMoreItem as RacListBoxLoadMoreItem, +} from 'react-aria-components/ComboBox'; +import { composeRenderProps } from 'react-aria-components/composeRenderProps'; +import * as styles from '../recipes/combobox.css.js'; +import type { DistributiveOmit } from '../types/distributive-omit.js'; +import { cx } from '../utils/index.js'; + +interface ComboboxStyleProps { + size?: 'small' | 'medium'; +} + +export interface ComboboxItemProps + extends DistributiveOmit, 'className'>, ComboboxStyleProps { + className?: RacListBoxItemProps['className']; +} + +export function ComboboxItem(props: ComboboxItemProps): JSX.Element { + const { size = 'medium', ...itemProps } = props; + + return ( + { + return cx(styles.comboboxItem({ size }), className); + })} + /> + ); +} + +export interface ComboboxLoadMoreItemProps + extends DistributiveOmit, ComboboxStyleProps { + className?: RacListBoxLoadMoreItemProps['className']; +} + +export function ComboboxLoadMoreItem(props: ComboboxLoadMoreItemProps): JSX.Element { + const { size = 'medium', ...loadMoreItemProps } = props; + + return ( + + ); +} diff --git a/packages/@luke-ui/react/src/combobox/listbox.tsx b/packages/@luke-ui/react/src/combobox/listbox.tsx new file mode 100644 index 00000000..b1926f43 --- /dev/null +++ b/packages/@luke-ui/react/src/combobox/listbox.tsx @@ -0,0 +1,52 @@ +import type { JSX, ReactNode } from 'react'; +import { Collection } from 'react-aria-components/Collection'; +import type { ListBoxProps as RacListBoxProps } from 'react-aria-components/ComboBox'; +import { ListBox as RacListBox } from 'react-aria-components/ComboBox'; +import { ListBoxContext } from 'react-aria-components/ListBox'; +import { composeRenderProps } from 'react-aria-components/composeRenderProps'; +import { useSlottedContext } from 'react-aria-components/slots'; +import * as styles from '../recipes/combobox.css.js'; +import type { DistributiveOmit } from '../types/distributive-omit.js'; +import { cx } from '../utils/index.js'; + +/** Props for the styled listbox. */ +export interface ComboboxListBoxProps extends DistributiveOmit< + RacListBoxProps, + 'dependencies' | 'items' +> { + /** Item content for the listbox (render prop or static children). */ + children?: RacListBoxProps['children']; + /** Values that should invalidate the dynamic item cache. */ + dependencies?: ReadonlyArray; + /** Dynamic items rendered by the `children` render prop. */ + items?: Iterable; + /** Optional content appended after the main collection, e.g. a load-more sentinel. */ + loadMoreItem?: ReactNode; +} + +/** Styled listbox for combobox options. */ +export function ComboboxListBox(props: ComboboxListBoxProps): JSX.Element { + const { children, dependencies, items, loadMoreItem, ...listBoxProps } = props; + const listBoxContext = useSlottedContext(ListBoxContext); + const collectionItems = items ?? listBoxContext?.items; + const listBoxChildren = + typeof children === 'function' ? ( + dependencies={dependencies} items={collectionItems}> + {children} + + ) : ( + children + ); + + return ( + { + return cx(styles.comboboxListBox(), className); + })} + > + {listBoxChildren} + {loadMoreItem} + + ); +} diff --git a/packages/@luke-ui/react/src/combobox/popover.tsx b/packages/@luke-ui/react/src/combobox/popover.tsx new file mode 100644 index 00000000..7011739a --- /dev/null +++ b/packages/@luke-ui/react/src/combobox/popover.tsx @@ -0,0 +1,22 @@ +import type { JSX } from 'react'; +import type { PopoverProps as RacPopoverProps } from 'react-aria-components/ComboBox'; +import { Popover as RacPopover } from 'react-aria-components/ComboBox'; +import { composeRenderProps } from 'react-aria-components/composeRenderProps'; +import * as styles from '../recipes/combobox.css.js'; +import { themeRootClassName } from '../theme/index.js'; +import { cx } from '../utils/index.js'; + +/** Props for the styled combobox popover. */ +export interface ComboboxPopoverProps extends Omit {} + +/** Popover surface used for listbox content. */ +export function ComboboxPopover(props: ComboboxPopoverProps): JSX.Element { + return ( + { + return cx(themeRootClassName, styles.comboboxPopover(), className); + })} + /> + ); +} diff --git a/packages/@luke-ui/react/src/combobox/root.tsx b/packages/@luke-ui/react/src/combobox/root.tsx new file mode 100644 index 00000000..f8afc42d --- /dev/null +++ b/packages/@luke-ui/react/src/combobox/root.tsx @@ -0,0 +1,55 @@ +import type { JSX } from 'react'; +import type { ComboBoxProps as RacComboBoxProps, Key } from 'react-aria-components/ComboBox'; +import { ComboBox as RacComboBox } from 'react-aria-components/ComboBox'; +import { composeRenderProps } from 'react-aria-components/composeRenderProps'; +import * as styles from '../recipes/combobox.css.js'; +import type { DistributiveOmit } from '../types/distributive-omit.js'; +import { cx } from '../utils/index.js'; + +interface ComboboxVariantProps extends NonNullable {} + +export type ComboboxSize = NonNullable; + +export interface ComboboxInputProps extends DistributiveOmit< + RacComboBoxProps, + | 'defaultSelectedKey' + | 'defaultValue' + | 'onChange' + | 'onOpenChange' + | 'onSelectionChange' + | 'selectedKey' + | 'selectionMode' + | 'value' +> { + /** The currently selected key (controlled). Pass `null` for no selection. */ + value?: Key | null; + + /** The initially selected key (uncontrolled). */ + defaultValue?: Key | null; + + /** Called when the selected value changes. */ + onChange?: (value: Key | null) => void; + + /** Called when the open state changes. */ + onOpenChange?: (isOpen: boolean) => void; + + /** + * The interaction required to display the ComboBox menu. + * @default 'focus' + */ + menuTrigger?: 'focus' | 'input' | 'manual'; +} + +export function ComboboxInput(props: ComboboxInputProps): JSX.Element { + const { className, menuTrigger = 'focus', ...comboboxProps } = props; + + return ( + { + return cx(styles.comboboxRoot, renderedClassName); + })} + /> + ); +} diff --git a/packages/@luke-ui/react/src/combobox/section.tsx b/packages/@luke-ui/react/src/combobox/section.tsx new file mode 100644 index 00000000..4200452b --- /dev/null +++ b/packages/@luke-ui/react/src/combobox/section.tsx @@ -0,0 +1,38 @@ +import type { JSX, ReactNode } from 'react'; +import type { ListBoxSectionProps as RacListBoxSectionProps } from 'react-aria-components/ComboBox'; +import { ListBoxSection as RacListBoxSection } from 'react-aria-components/ComboBox'; +import { Header as RacHeader } from 'react-aria-components/Header'; +import * as styles from '../recipes/combobox.css.js'; +import type { DistributiveOmit } from '../types/distributive-omit.js'; +import { cx } from '../utils/index.js'; + +export interface ComboboxSectionProps extends DistributiveOmit< + RacListBoxSectionProps, + 'className' +> { + className?: RacListBoxSectionProps['className']; + title?: ReactNode; +} + +export function ComboboxSection(props: ComboboxSectionProps): JSX.Element { + const { children, className, title, ...sectionProps } = props; + + const sectionClassName = cx(styles.comboboxSection(), className); + + if (typeof children === 'function') { + return ( + + {children} + + ); + } + + return ( + + {title != null ? ( + {title} + ) : null} + {children} + + ); +} diff --git a/packages/@luke-ui/react/src/combobox/trigger.tsx b/packages/@luke-ui/react/src/combobox/trigger.tsx new file mode 100644 index 00000000..ec0cccd1 --- /dev/null +++ b/packages/@luke-ui/react/src/combobox/trigger.tsx @@ -0,0 +1,33 @@ +import type { JSX } from 'react'; +import type { ButtonProps as RacButtonProps } from 'react-aria-components/ComboBox'; +import { Button as RacButton } from 'react-aria-components/ComboBox'; +import { composeRenderProps } from 'react-aria-components/composeRenderProps'; +import * as styles from '../recipes/combobox.css.js'; +import type { DistributiveOmit } from '../types/distributive-omit.js'; +import { cx } from '../utils/index.js'; + +interface ComboboxVariantProps extends NonNullable {} + +interface ComboboxStyleProps { + size?: ComboboxVariantProps['size']; +} + +/** Props for the combobox trigger button. */ +export interface ComboboxTriggerProps + extends DistributiveOmit, ComboboxStyleProps { + className?: RacButtonProps['className']; +} + +/** Trigger button used by combobox pattern. */ +export function ComboboxTrigger(props: ComboboxTriggerProps): JSX.Element { + const { size = 'medium', ...buttonProps } = props; + + return ( + { + return cx(styles.comboboxTrigger({ size }), className); + })} + /> + ); +} diff --git a/packages/@luke-ui/react/src/emoji/index.tsx b/packages/@luke-ui/react/src/emoji/index.tsx index 11d3795b..680bc050 100644 --- a/packages/@luke-ui/react/src/emoji/index.tsx +++ b/packages/@luke-ui/react/src/emoji/index.tsx @@ -1,6 +1,6 @@ import type { TextProps } from '../text/index.js'; import { Text } from '../text/index.js'; -import type { DistributiveOmit } from '../types/index.js'; +import type { DistributiveOmit } from '../types/distributive-omit.js'; /** Props for `Emoji`. */ export interface EmojiProps extends DistributiveOmit { diff --git a/packages/@luke-ui/react/src/field.docs.mdx b/packages/@luke-ui/react/src/field.docs.mdx index c8f46806..af7c1741 100644 --- a/packages/@luke-ui/react/src/field.docs.mdx +++ b/packages/@luke-ui/react/src/field.docs.mdx @@ -6,7 +6,7 @@ controls. Primitive building blocks: ```ts -import { Field, FieldDescription, FieldError, FieldLabel } from '@luke-ui/react/field/primitive'; +import { Field, FieldDescription, FieldError, FieldLabel } from '@luke-ui/react/field'; ``` Opinionated composed component: diff --git a/packages/@luke-ui/react/src/field-description/index.tsx b/packages/@luke-ui/react/src/field/description.tsx similarity index 100% rename from packages/@luke-ui/react/src/field-description/index.tsx rename to packages/@luke-ui/react/src/field/description.tsx diff --git a/packages/@luke-ui/react/src/field-error/index.tsx b/packages/@luke-ui/react/src/field/error.tsx similarity index 100% rename from packages/@luke-ui/react/src/field-error/index.tsx rename to packages/@luke-ui/react/src/field/error.tsx diff --git a/packages/@luke-ui/react/src/field/index.tsx b/packages/@luke-ui/react/src/field/index.tsx index 6590b445..ef8d01bf 100644 --- a/packages/@luke-ui/react/src/field/index.tsx +++ b/packages/@luke-ui/react/src/field/index.tsx @@ -1,10 +1,14 @@ import type { ComponentProps, JSX, ReactNode } from 'react'; -import { FieldDescription } from '../field-description/index.js'; -import type { FieldErrorProps } from '../field-error/index.js'; -import { FieldError } from '../field-error/index.js'; -import type { FieldNecessityIndicator } from '../field-label/index.js'; -import { FieldLabel } from '../field-label/index.js'; -import { Field as PrimitiveField } from '../field/primitive.js'; +import { FieldDescription } from './description.js'; +import type { FieldDescriptionProps } from './description.js'; +import type { FieldErrorProps } from './error.js'; +import { FieldError } from './error.js'; +import { FieldLabel } from './label.js'; +import type { FieldLabelProps, FieldNecessityIndicator } from './label.js'; +import { Field as PrimitiveField } from './primitive.js'; + +export { FieldDescription, FieldError, FieldLabel }; +export type { FieldDescriptionProps, FieldErrorProps, FieldLabelProps, FieldNecessityIndicator }; type PrimitiveFieldProps = ComponentProps; diff --git a/packages/@luke-ui/react/src/field-label/index.tsx b/packages/@luke-ui/react/src/field/label.tsx similarity index 100% rename from packages/@luke-ui/react/src/field-label/index.tsx rename to packages/@luke-ui/react/src/field/label.tsx diff --git a/packages/@luke-ui/react/src/heading/index.tsx b/packages/@luke-ui/react/src/heading/index.tsx index 7806f681..d1c6c712 100644 --- a/packages/@luke-ui/react/src/heading/index.tsx +++ b/packages/@luke-ui/react/src/heading/index.tsx @@ -2,7 +2,7 @@ import type { HeadingLevel } from '../heading-context/index.js'; import { HeadingLevels, HeadingPresenceProvider } from '../heading-context/index.js'; import type { TextProps } from '../text/index.js'; import { Text } from '../text/index.js'; -import type { DistributiveOmit } from '../types/index.js'; +import type { DistributiveOmit } from '../types/distributive-omit.js'; export type { HeadingLevel } from '../heading-context/index.js'; /** Valid heading tag name for Luke UI headings. */ diff --git a/packages/@luke-ui/react/src/icon/index.tsx b/packages/@luke-ui/react/src/icon/index.tsx index 715e479d..f98d17a2 100644 --- a/packages/@luke-ui/react/src/icon/index.tsx +++ b/packages/@luke-ui/react/src/icon/index.tsx @@ -2,9 +2,9 @@ import type { JSX, ReactNode, SVGAttributes } from 'react'; import { createContext, useContext } from 'react'; import { iconNames, iconViewBoxes } from '../../.generated/icon-data.js'; import { useIconSizeContext } from '../icon-size-context/index.js'; -import { ICON_VIEWBOX } from '../lib/icon.js'; import * as styles from '../recipes/icon.css.js'; -import type { DistributiveOmit } from '../types/index.js'; +import { ICON_VIEWBOX } from '../sizing/icon-sizing.js'; +import type { DistributiveOmit } from '../types/distributive-omit.js'; import { cx } from '../utils/index.js'; export type { IconName } from '../../.generated/icon-data.js'; diff --git a/packages/@luke-ui/react/src/lib/button.ts b/packages/@luke-ui/react/src/lib/button.ts deleted file mode 100644 index 1ca77174..00000000 --- a/packages/@luke-ui/react/src/lib/button.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { FontSizeToken, IconSizeToken } from '../tokens/index.js'; - -/** Button size → icon/spinner size so icons align with control size. */ -export const BUTTON_ICON_SIZE = { - medium: 'medium', - small: 'xsmall', -} as const satisfies Record<'medium' | 'small', IconSizeToken>; - -/** Button size → Text fontSize for composed button label. */ -export const BUTTON_FONT_SIZE = { - medium: 'standard', - small: 'small', -} as const satisfies Record<'medium' | 'small', FontSizeToken>; diff --git a/packages/@luke-ui/react/src/loading-spinner/index.tsx b/packages/@luke-ui/react/src/loading-spinner/index.tsx index a3f34bd5..7d02dd88 100644 --- a/packages/@luke-ui/react/src/loading-spinner/index.tsx +++ b/packages/@luke-ui/react/src/loading-spinner/index.tsx @@ -1,13 +1,13 @@ import { clamp } from '@react-aria/utils'; import type { ComponentProps } from 'react'; +import * as styles from '../recipes/loading-spinner.css.js'; import { ICON_VIEWBOX, ICON_VIEWBOX_SIZE, SPINNER_CIRCLE_RADIUS, SPINNER_STROKE_WIDTH, -} from '../lib/icon.js'; -import * as styles from '../recipes/loading-spinner.css.js'; -import type { DistributiveOmit } from '../types/index.js'; +} from '../sizing/icon-sizing.js'; +import type { DistributiveOmit } from '../types/distributive-omit.js'; import { cx } from '../utils/index.js'; interface LoadingSpinnerVariantProps extends NonNullable {} diff --git a/packages/@luke-ui/react/src/recipes/layers.css.ts b/packages/@luke-ui/react/src/recipes/layers.css.ts new file mode 100644 index 00000000..c86fd247 --- /dev/null +++ b/packages/@luke-ui/react/src/recipes/layers.css.ts @@ -0,0 +1,10 @@ +import './button.css.js'; +import './button-composed.css.js'; +import './combobox.css.js'; +import './field.css.js'; +import './icon.css.js'; +import './icon-button.css.js'; +import './link.css.js'; +import './loading-spinner.css.js'; +import './text.css.js'; +import './text-input.css.js'; diff --git a/packages/@luke-ui/react/src/recipes/loading-spinner.css.ts b/packages/@luke-ui/react/src/recipes/loading-spinner.css.ts index a1b67bf3..d953a502 100644 --- a/packages/@luke-ui/react/src/recipes/loading-spinner.css.ts +++ b/packages/@luke-ui/react/src/recipes/loading-spinner.css.ts @@ -2,7 +2,8 @@ import { keyframes } from '@vanilla-extract/css'; import type { RecipeVariants } from '@vanilla-extract/recipes'; import { recipeInLayer, styleInLayer } from '../styles/layered-style.css.js'; import { vars } from '../styles/vars.css.js'; -import { tokenKeys, tokens } from '../tokens/index.js'; +import { tokenKeys } from '../tokens/groups.js'; +import { tokens } from '../tokens/index.js'; import { iconSizeVariants } from './icon.css.js'; const colorKeys = tokenKeys(tokens.foregroundColor); diff --git a/packages/@luke-ui/react/src/recipes/text.css.ts b/packages/@luke-ui/react/src/recipes/text.css.ts index c9e968d7..f28076a8 100644 --- a/packages/@luke-ui/react/src/recipes/text.css.ts +++ b/packages/@luke-ui/react/src/recipes/text.css.ts @@ -1,8 +1,9 @@ import type { ComplexStyleRule } from '@vanilla-extract/css'; import type { RecipeVariants } from '@vanilla-extract/recipes'; -import { createPropertyVariants, createVariants } from '../style-helpers/index.js'; +import { createPropertyVariants, createVariants } from '../style-helpers/create-variants.js'; import { recipeInLayer, styleInLayer } from '../styles/layered-style.css.js'; import { vars } from '../styles/vars.css.js'; +import { tokenKeys } from '../tokens/groups.js'; import type { FontFamilyToken, FontSizeToken, @@ -10,8 +11,8 @@ import type { ForegroundColorToken, LineHeightToken, } from '../tokens/index.js'; -import { tokenKeys, tokens } from '../tokens/index.js'; -import { getTypographyClass } from '../typography/index.js'; +import { tokens } from '../tokens/index.js'; +import { getTypographyClass } from '../typography/get-typography-class.js'; const colorKeys = tokenKeys(tokens.foregroundColor); const fontFamilyKeys = tokenKeys(tokens.fontFamily); diff --git a/packages/@luke-ui/react/src/sizing/button-sizing.ts b/packages/@luke-ui/react/src/sizing/button-sizing.ts new file mode 100644 index 00000000..7211d481 --- /dev/null +++ b/packages/@luke-ui/react/src/sizing/button-sizing.ts @@ -0,0 +1,13 @@ +import type { FontSizeToken, IconSizeToken } from '../tokens/index.js'; + +/** Maps button size to the appropriate icon/spinner size. */ +export const BUTTON_ICON_SIZE: Record<'medium' | 'small', IconSizeToken> = { + medium: 'medium', + small: 'xsmall', +}; + +/** Maps button size to the appropriate Text fontSize for composed button label. */ +export const BUTTON_FONT_SIZE: Record<'medium' | 'small', FontSizeToken> = { + medium: 'standard', + small: 'small', +}; diff --git a/packages/@luke-ui/react/src/lib/icon.ts b/packages/@luke-ui/react/src/sizing/icon-sizing.ts similarity index 88% rename from packages/@luke-ui/react/src/lib/icon.ts rename to packages/@luke-ui/react/src/sizing/icon-sizing.ts index a05800b8..0dffce9a 100644 --- a/packages/@luke-ui/react/src/lib/icon.ts +++ b/packages/@luke-ui/react/src/sizing/icon-sizing.ts @@ -1,4 +1,4 @@ -/** Shared viewBox size for icon SVGs (icons and LoadingSpinner). */ +/** Shared viewBox size for icon SVGs. */ export const ICON_VIEWBOX_SIZE = 24; /** Inset from viewBox edge to content; icons use 19×19 content in 24×24 box. */ @@ -10,6 +10,7 @@ const ICON_CONTENT_SIZE = ICON_VIEWBOX_SIZE - 2 * ICON_CONTENT_INSET; /** Default viewBox string for icon-aligned SVGs. */ export const ICON_VIEWBOX = `0 0 ${ICON_VIEWBOX_SIZE} ${ICON_VIEWBOX_SIZE}`; +/** Stroke width for loading spinner. */ export const SPINNER_STROKE_WIDTH = 2; /** Radius inset by half stroke so the stroke's outer edge aligns with icon content (19px). */ diff --git a/packages/@luke-ui/react/src/style-helpers/index.ts b/packages/@luke-ui/react/src/style-helpers/create-variants.ts similarity index 100% rename from packages/@luke-ui/react/src/style-helpers/index.ts rename to packages/@luke-ui/react/src/style-helpers/create-variants.ts diff --git a/packages/@luke-ui/react/src/styles/composed.css.ts b/packages/@luke-ui/react/src/styles/composed.css.ts deleted file mode 100644 index 97750e81..00000000 --- a/packages/@luke-ui/react/src/styles/composed.css.ts +++ /dev/null @@ -1 +0,0 @@ -import '../recipes/icon-button.css.js'; diff --git a/packages/@luke-ui/react/src/styles/index.css.ts b/packages/@luke-ui/react/src/styles/index.css.ts index 2ba0623e..54613b86 100644 --- a/packages/@luke-ui/react/src/styles/index.css.ts +++ b/packages/@luke-ui/react/src/styles/index.css.ts @@ -1,4 +1,3 @@ import './vars.css.js'; import './reset.css.js'; import './primitives.css.js'; -import './composed.css.js'; diff --git a/packages/@luke-ui/react/src/styles/layered-style.css.ts b/packages/@luke-ui/react/src/styles/layered-style.css.ts index 599ec713..b33bc569 100644 --- a/packages/@luke-ui/react/src/styles/layered-style.css.ts +++ b/packages/@luke-ui/react/src/styles/layered-style.css.ts @@ -1,7 +1,7 @@ import type { GlobalStyleRule, StyleRule } from '@vanilla-extract/css'; import { globalStyle as vanillaGlobalStyle, style as vanillaStyle } from '@vanilla-extract/css'; import { recipe as vanillaRecipe } from '@vanilla-extract/recipes'; -import type { DistributiveOmit } from '../types/index.js'; +import type { DistributiveOmit } from '../types/distributive-omit.js'; import type { LayerName } from './layers.css.js'; import { layers } from './layers.css.js'; diff --git a/packages/@luke-ui/react/src/styles/primitives.css.ts b/packages/@luke-ui/react/src/styles/primitives.css.ts index 2ea4f5d2..52495129 100644 --- a/packages/@luke-ui/react/src/styles/primitives.css.ts +++ b/packages/@luke-ui/react/src/styles/primitives.css.ts @@ -1,10 +1 @@ -import '../recipes/button-composed.css.js'; -import '../recipes/button.css.js'; -import '../recipes/field.css.js'; -import '../recipes/icon-button.css.js'; -import '../recipes/icon.css.js'; -import '../recipes/link.css.js'; -import '../recipes/loading-spinner.css.js'; -import '../recipes/combobox.css.js'; -import '../recipes/text-input.css.js'; -import '../recipes/text.css.js'; +import '../recipes/layers.css.js'; diff --git a/packages/@luke-ui/react/src/styles/vars.css.ts b/packages/@luke-ui/react/src/styles/vars.css.ts index e3e0ca5c..280437e3 100644 --- a/packages/@luke-ui/react/src/styles/vars.css.ts +++ b/packages/@luke-ui/react/src/styles/vars.css.ts @@ -1,13 +1,13 @@ import { createTheme, createThemeContract } from '@vanilla-extract/css'; -import type { ColorTokenValue } from '../tokens/index.js'; import { colorToCssString, cubicBezierToString, dimensionToRemString, durationToString, - tokenKeys, - tokens, -} from '../tokens/index.js'; +} from '../tokens/converters.js'; +import { tokenKeys } from '../tokens/groups.js'; +import type { ColorTokenValue } from '../tokens/index.js'; +import { tokens } from '../tokens/index.js'; import { classSelector, lukeUiClassNames } from './class-names.js'; import { globalStyleInLayer } from './layered-style.css.js'; import { layers } from './layers.css.js'; diff --git a/packages/@luke-ui/react/src/text-field/index.tsx b/packages/@luke-ui/react/src/text-field/index.tsx index 721eb232..962e28e0 100644 --- a/packages/@luke-ui/react/src/text-field/index.tsx +++ b/packages/@luke-ui/react/src/text-field/index.tsx @@ -4,9 +4,9 @@ import type { TextFieldProps as RacTextFieldProps, } from 'react-aria-components/TextField'; import { TextField as RacTextField } from 'react-aria-components/TextField'; -import type { FieldErrorProps } from '../field-error/index.js'; -import type { FieldNecessityIndicator } from '../field-label/index.js'; +import type { FieldErrorProps } from '../field/error.js'; import { Field } from '../field/index.js'; +import type { FieldNecessityIndicator } from '../field/label.js'; import type { TextInputSize } from '../text-input/index.js'; import { TextInput } from '../text-input/index.js'; diff --git a/packages/@luke-ui/react/src/tokens/converters.ts b/packages/@luke-ui/react/src/tokens/converters.ts new file mode 100644 index 00000000..4e589d2e --- /dev/null +++ b/packages/@luke-ui/react/src/tokens/converters.ts @@ -0,0 +1,50 @@ +import { pxToRem } from '../utils/index.js'; +import type { + ColorTokenValue, + CubicBezierTokenValue, + DimensionTokenValue, + DurationTokenValue, +} from './index.js'; + +export function dimensionToRemString(value: DimensionTokenValue, base: number = 16): string { + return value.unit === 'rem' ? `${value.value}rem` : pxToRem(value.value, base); +} + +export function dimensionToPxNumber(value: DimensionTokenValue, base: number = 16): number { + return value.unit === 'px' ? value.value : value.value * base; +} + +export function durationToString(value: DurationTokenValue): string { + return `${value.value}${value.unit}`; +} + +export function cubicBezierToString(value: CubicBezierTokenValue): string { + return `cubic-bezier(${value[0]}, ${value[1]}, ${value[2]}, ${value[3]})`; +} + +const functionLikeColorSpaces = new Set(['hsl', 'hwb', 'lab', 'lch', 'oklab', 'oklch']); + +export function colorToCssString(value: ColorTokenValue): string { + if (value.components.length === 0) { + throw new Error(`Color token "${value.colorSpace}" has no components`); + } + + const components = value.components.map((component) => formatNumber(component)); + const alphaSuffix = + value.alpha === undefined || value.alpha === 1 ? '' : ` / ${formatNumber(value.alpha)}`; + + if (functionLikeColorSpaces.has(value.colorSpace)) { + return `${value.colorSpace}(${components.join(' ')}${alphaSuffix})`; + } + + return `color(${value.colorSpace} ${components.join(' ')}${alphaSuffix})`; +} + +function formatNumber(value: number): string { + if (!Number.isFinite(value)) { + throw new Error(`Invalid numeric color component: ${value}`); + } + + const normalized = Object.is(value, -0) ? 0 : value; + return `${normalized}`; +} diff --git a/packages/@luke-ui/react/src/tokens/groups.ts b/packages/@luke-ui/react/src/tokens/groups.ts new file mode 100644 index 00000000..7a38378d --- /dev/null +++ b/packages/@luke-ui/react/src/tokens/groups.ts @@ -0,0 +1,24 @@ +import type { DesignTokenGroup, TokenName } from './index.js'; + +export function toTokenGroup>( + type: TType, + values: TValues, +): DesignTokenGroup { + const group = { $type: type } as DesignTokenGroup; + + for (const key in values) { + (group as Record)[key] = { + $value: values[key], + }; + } + + return group; +} + +export function tokenKeys( + group: TGroup, +): Array> { + return (Object.keys(group) as Array).filter( + (key): key is TokenName => key !== '$type', + ); +} diff --git a/packages/@luke-ui/react/src/tokens/index.ts b/packages/@luke-ui/react/src/tokens/index.ts index 19525247..4521199f 100644 --- a/packages/@luke-ui/react/src/tokens/index.ts +++ b/packages/@luke-ui/react/src/tokens/index.ts @@ -4,7 +4,23 @@ import { foregroundColorTokenValues, themeColorTokenValues, } from '../../.generated/color-tokens.generated.js'; -import { pxToRem, typedKeys } from '../utils/index.js'; +import { toTokenGroup } from './groups.js'; +import { minMediaQueries } from './media-queries.js'; +import { + borderRadiusValues, + borderWidthValues, + boxShadowValues, + breakpointValues, + controlSizeValues, + fontFamilyValues, + fontSizeValues, + fontWeightValues, + iconSizeValues, + lineHeightValues, + motionDurationValues, + motionEasingValues, + spaceValues, +} from './values.js'; export interface DimensionTokenValue { unit: 'px' | 'rem'; @@ -39,183 +55,30 @@ export type TokenName = Extract< string >; -function toTokenGroup>( - type: TType, - values: TValues, -): DesignTokenGroup { - const group = { $type: type } as DesignTokenGroup; - - for (const key in values) { - (group as Record)[key] = { - $value: values[key], - }; - } - - return group; -} - -export function tokenKeys( - group: TGroup, -): Array> { - return (Object.keys(group) as Array).filter( - (key): key is TokenName => key !== '$type', - ); -} - -export function dimensionToRemString(value: DimensionTokenValue, base: number = 16): string { - return value.unit === 'rem' ? `${value.value}rem` : pxToRem(value.value, base); -} - -export function dimensionToPxNumber(value: DimensionTokenValue, base: number = 16): number { - return value.unit === 'px' ? value.value : value.value * base; -} - -export function durationToString(value: DurationTokenValue): string { - return `${value.value}${value.unit}`; -} - -export function cubicBezierToString(value: CubicBezierTokenValue): string { - return `cubic-bezier(${value[0]}, ${value[1]}, ${value[2]}, ${value[3]})`; -} - -function formatNumber(value: number): string { - if (!Number.isFinite(value)) { - throw new Error(`Invalid numeric color component: ${value}`); - } - - const normalized = Object.is(value, -0) ? 0 : value; - return `${normalized}`; -} - -const functionLikeColorSpaces = new Set(['hsl', 'hwb', 'lab', 'lch', 'oklab', 'oklch']); - -export function colorToCssString(value: ColorTokenValue): string { - if (value.components.length === 0) { - throw new Error(`Color token "${value.colorSpace}" has no components`); - } - - const components = value.components.map((component) => formatNumber(component)); - const alphaSuffix = - value.alpha === undefined || value.alpha === 1 ? '' : ` / ${formatNumber(value.alpha)}`; - - if (functionLikeColorSpaces.has(value.colorSpace)) { - return `${value.colorSpace}(${components.join(' ')}${alphaSuffix})`; - } - - return `color(${value.colorSpace} ${components.join(' ')}${alphaSuffix})`; -} - -const breakpointValues = { - xsmall: { unit: 'px', value: 0 }, - small: { unit: 'px', value: 640 }, - medium: { unit: 'px', value: 768 }, - large: { unit: 'px', value: 1024 }, - xlarge: { unit: 'px', value: 1280 }, - xxlarge: { unit: 'px', value: 1536 }, -} as const satisfies Record; - -type Breakpoint = keyof typeof breakpointValues; - -const minMediaQueries = Object.fromEntries( - typedKeys(breakpointValues).map((breakpoint) => [ - breakpoint, - `@media(width >= ${dimensionToRemString(breakpointValues[breakpoint])})`, - ]), -) as Record; - -const borderRadiusValues = { - none: { unit: 'px', value: 0 }, - small: { unit: 'px', value: 2 }, - medium: { unit: 'px', value: 4 }, - large: { unit: 'px', value: 8 }, - xlarge: { unit: 'px', value: 16 }, - full: { unit: 'px', value: 9999 }, -} as const satisfies Record; - -const borderWidthValues = { - thin: { unit: 'px', value: 1 }, - thick: { unit: 'px', value: 2 }, -} as const satisfies Record; - -const boxShadowValues = { - none: 'none', - xsmall: '0px 1px 2px rgba(0, 0, 0, 0.2)', - small: '0px 2px 4px rgba(0, 0, 0, 0.2)', - medium: '0px 2px 8px rgba(0, 0, 0, 0.2)', - large: '0px 4px 16px rgba(0, 0, 0, 0.2)', - xlarge: '-8px 8px 32px rgba(0, 0, 0, 0.2)', -} as const; - -const controlSizeValues = { - small: { unit: 'px', value: 32 }, - medium: { unit: 'px', value: 40 }, -} as const satisfies Record; - -const fontFamilyValues = { - sans: "ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'", - mono: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace", -} as const; - -const fontSizeValues = { - xxsmall: { unit: 'px', value: 10 }, - xsmall: { unit: 'px', value: 12 }, - small: { unit: 'px', value: 14 }, - standard: { unit: 'px', value: 16 }, - medium: { unit: 'px', value: 20 }, - large: { unit: 'px', value: 40 }, - xlarge: { unit: 'px', value: 48 }, - xxlarge: { unit: 'px', value: 55 }, - h1: { unit: 'px', value: 36 }, - h2: { unit: 'px', value: 28 }, - h3: { unit: 'px', value: 24 }, - h4: { unit: 'px', value: 20 }, - h5: { unit: 'px', value: 18 }, - h6: { unit: 'px', value: 16 }, -} as const satisfies Record; - -const fontWeightValues = { - regular: 400, - medium: 500, - bold: 700, -} as const; - -const iconSizeValues = { - xsmall: { unit: 'px', value: 16 }, - small: { unit: 'px', value: 20 }, - medium: { unit: 'px', value: 24 }, - large: { unit: 'px', value: 32 }, -} as const satisfies Record; - -const lineHeightValues = { - nospace: 1, - tight: 1.25, - loose: 1.5, -} as const; - -const motionDurationValues = { - quick: { unit: 'ms', value: 120 }, - fast: { unit: 'ms', value: 150 }, - slow: { unit: 's', value: 1.2 }, - slower: { unit: 's', value: 2 }, -} as const satisfies Record; - -const motionEasingValues = { - emphasized: [0.42, 0, 0.58, 1], - exit: [0, 0, 0.58, 1], - linear: [0, 0, 1, 1], - standard: [0.4, 0, 0.2, 1], -} as const satisfies Record; - -const spaceValues = { - none: { unit: 'px', value: 0 }, - xxsmall: { unit: 'px', value: 4 }, - xsmall: { unit: 'px', value: 8 }, - small: { unit: 'px', value: 12 }, - medium: { unit: 'px', value: 16 }, - large: { unit: 'px', value: 24 }, - xlarge: { unit: 'px', value: 32 }, - xxlarge: { unit: 'px', value: 40 }, -} as const satisfies Record; +export { + colorToCssString, + cubicBezierToString, + dimensionToRemString, + durationToString, +} from './converters.js'; +export { tokenKeys, toTokenGroup } from './groups.js'; +export { minMediaQueries } from './media-queries.js'; +export type { Breakpoint } from './media-queries.js'; +export { + borderRadiusValues, + borderWidthValues, + boxShadowValues, + breakpointValues, + controlSizeValues, + fontFamilyValues, + fontSizeValues, + fontWeightValues, + iconSizeValues, + lineHeightValues, + motionDurationValues, + motionEasingValues, + spaceValues, +} from './values.js'; export const tokens = { backgroundColor: toTokenGroup('color', backgroundColorTokenValues), diff --git a/packages/@luke-ui/react/src/tokens/media-queries.ts b/packages/@luke-ui/react/src/tokens/media-queries.ts new file mode 100644 index 00000000..7ea6bd3b --- /dev/null +++ b/packages/@luke-ui/react/src/tokens/media-queries.ts @@ -0,0 +1,12 @@ +import { typedKeys } from '../utils/index.js'; +import { dimensionToRemString } from './converters.js'; +import { breakpointValues } from './values.js'; + +export type Breakpoint = keyof typeof breakpointValues; + +export const minMediaQueries = Object.fromEntries( + typedKeys(breakpointValues).map((breakpoint) => [ + breakpoint, + `@media(width >= ${dimensionToRemString(breakpointValues[breakpoint])})`, + ]), +) as Record; diff --git a/packages/@luke-ui/react/src/tokens/values.ts b/packages/@luke-ui/react/src/tokens/values.ts new file mode 100644 index 00000000..4856e0f8 --- /dev/null +++ b/packages/@luke-ui/react/src/tokens/values.ts @@ -0,0 +1,104 @@ +import type { DimensionTokenValue, DurationTokenValue, CubicBezierTokenValue } from './index.js'; + +export const breakpointValues = { + xsmall: { unit: 'px', value: 0 }, + small: { unit: 'px', value: 640 }, + medium: { unit: 'px', value: 768 }, + large: { unit: 'px', value: 1024 }, + xlarge: { unit: 'px', value: 1280 }, + xxlarge: { unit: 'px', value: 1536 }, +} as const satisfies Record; + +export const borderRadiusValues = { + none: { unit: 'px', value: 0 }, + small: { unit: 'px', value: 2 }, + medium: { unit: 'px', value: 4 }, + large: { unit: 'px', value: 8 }, + xlarge: { unit: 'px', value: 16 }, + full: { unit: 'px', value: 9999 }, +} as const satisfies Record; + +export const borderWidthValues = { + thin: { unit: 'px', value: 1 }, + thick: { unit: 'px', value: 2 }, +} as const satisfies Record; + +export const boxShadowValues = { + none: 'none', + xsmall: '0px 1px 2px rgba(0, 0, 0, 0.2)', + small: '0px 2px 4px rgba(0, 0, 0, 0.2)', + medium: '0px 2px 8px rgba(0, 0, 0, 0.2)', + large: '0px 4px 16px rgba(0, 0, 0, 0.2)', + xlarge: '-8px 8px 32px rgba(0, 0, 0, 0.2)', +} as const; + +export const controlSizeValues = { + small: { unit: 'px', value: 32 }, + medium: { unit: 'px', value: 40 }, +} as const satisfies Record; + +export const fontFamilyValues = { + sans: "ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'", + mono: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace", +} as const; + +export const fontSizeValues = { + xxsmall: { unit: 'px', value: 10 }, + xsmall: { unit: 'px', value: 12 }, + small: { unit: 'px', value: 14 }, + standard: { unit: 'px', value: 16 }, + medium: { unit: 'px', value: 20 }, + large: { unit: 'px', value: 40 }, + xlarge: { unit: 'px', value: 48 }, + xxlarge: { unit: 'px', value: 55 }, + h1: { unit: 'px', value: 36 }, + h2: { unit: 'px', value: 28 }, + h3: { unit: 'px', value: 24 }, + h4: { unit: 'px', value: 20 }, + h5: { unit: 'px', value: 18 }, + h6: { unit: 'px', value: 16 }, +} as const satisfies Record; + +export const fontWeightValues = { + regular: 400, + medium: 500, + bold: 700, +} as const; + +export const iconSizeValues = { + xsmall: { unit: 'px', value: 16 }, + small: { unit: 'px', value: 20 }, + medium: { unit: 'px', value: 24 }, + large: { unit: 'px', value: 32 }, +} as const satisfies Record; + +export const lineHeightValues = { + nospace: 1, + tight: 1.25, + loose: 1.5, +} as const; + +export const motionDurationValues = { + quick: { unit: 'ms', value: 120 }, + fast: { unit: 'ms', value: 150 }, + slow: { unit: 's', value: 1.2 }, + slower: { unit: 's', value: 2 }, +} as const satisfies Record; + +export const motionEasingValues = { + emphasized: [0.42, 0, 0.58, 1], + exit: [0, 0, 0.58, 1], + linear: [0, 0, 1, 1], + standard: [0.4, 0, 0.2, 1], +} as const satisfies Record; + +export const spaceValues = { + none: { unit: 'px', value: 0 }, + xxsmall: { unit: 'px', value: 4 }, + xsmall: { unit: 'px', value: 8 }, + small: { unit: 'px', value: 12 }, + medium: { unit: 'px', value: 16 }, + large: { unit: 'px', value: 24 }, + xlarge: { unit: 'px', value: 32 }, + xxlarge: { unit: 'px', value: 40 }, +} as const satisfies Record; diff --git a/packages/@luke-ui/react/src/types/index.ts b/packages/@luke-ui/react/src/types/distributive-omit.ts similarity index 100% rename from packages/@luke-ui/react/src/types/index.ts rename to packages/@luke-ui/react/src/types/distributive-omit.ts diff --git a/packages/@luke-ui/react/src/typography/index.ts b/packages/@luke-ui/react/src/typography/get-typography-class.ts similarity index 85% rename from packages/@luke-ui/react/src/typography/index.ts rename to packages/@luke-ui/react/src/typography/get-typography-class.ts index 85ab8da5..0b1023a8 100644 --- a/packages/@luke-ui/react/src/typography/index.ts +++ b/packages/@luke-ui/react/src/typography/get-typography-class.ts @@ -1,7 +1,8 @@ import fontMetrics from '@capsizecss/metrics/appleSystem'; import { createTextStyle } from '@capsizecss/vanilla-extract'; +import { dimensionToPxNumber } from '../tokens/converters.js'; import type { FontSizeToken, LineHeightToken } from '../tokens/index.js'; -import { dimensionToPxNumber, tokens } from '../tokens/index.js'; +import { tokens } from '../tokens/index.js'; interface GetTypographyInput { fontSize: FontSizeToken; diff --git a/packages/turbo-generators/config.ts b/packages/turbo-generators/config.ts index 42c5ebcd..adf00162 100644 --- a/packages/turbo-generators/config.ts +++ b/packages/turbo-generators/config.ts @@ -5,13 +5,19 @@ import type { PlopTypes } from '@turbo/gen'; const DOCS_GROUPS = ['actions', 'feedback', 'forms', 'typography', 'visuals'] as const; +const COMPONENT_NAME_RE = /^[A-Za-z][A-Za-z0-9-]*$/; +const CAMEL_BOUNDARY_RE = /([a-z0-9])([A-Z])/g; +const NON_ALPHANUM_RE = /[^A-Za-z0-9-]/g; +const TRAILING_WHITESPACE_RE = /\s+$/; +const TRAILING_NEWLINES_RE = /\n*$/; + type GeneratorAnswers = { group?: string; name?: string; }; function validateComponentName(value: unknown): true | string { - if (!value || typeof value !== 'string') { + if (typeof value !== 'string') { return 'Component name is required.'; } @@ -20,7 +26,7 @@ function validateComponentName(value: unknown): true | string { return 'Component name is required.'; } - if (!/^[A-Za-z][A-Za-z0-9-]*$/.test(trimmed)) { + if (!COMPONENT_NAME_RE.test(trimmed)) { return 'Use letters/numbers/hyphens. Start with a letter.'; } @@ -30,29 +36,46 @@ function validateComponentName(value: unknown): true | string { function toKebabCase(value: string): string { return value .trim() - .replaceAll(/([a-z0-9])([A-Z])/g, '$1-$2') - .replaceAll(/[^A-Za-z0-9-]/g, '-') + .replaceAll(CAMEL_BOUNDARY_RE, '$1-$2') + .replaceAll(NON_ALPHANUM_RE, '-') .toLowerCase(); } function toDisplayName(value: string): string { - return toKebabCase(value) - .split('-') - .filter(Boolean) - .map((part) => part[0]?.toUpperCase() + part.slice(1)) - .join(' '); + const kebab = toKebabCase(value); + let result = ''; + let capitalize = true; + + for (let i = 0; i < kebab.length; i += 1) { + const ch = kebab.charAt(i); + if (ch === '-') { + result += ' '; + capitalize = true; + } else { + result += capitalize ? ch.toUpperCase() : ch; + capitalize = false; + } + } + + return result; } function toCamelCase(value: string): string { - const parts = toKebabCase(value).split('-').filter(Boolean); - if (parts.length === 0) { - return ''; + const kebab = toKebabCase(value); + let result = ''; + let capitalize = false; + + for (let i = 0; i < kebab.length; i += 1) { + const ch = kebab.charAt(i); + if (ch === '-') { + capitalize = true; + } else { + result += capitalize ? ch.toUpperCase() : ch; + capitalize = false; + } } - return parts - .map((part, index) => { - return index === 0 ? part : (part[0]?.toUpperCase() ?? '') + part.slice(1); - }) - .join(''); + + return result; } function resolveGroup(answers: GeneratorAnswers): string { @@ -72,10 +95,20 @@ function resolveComponentDir(answers: GeneratorAnswers): string { return toKebabCase(name); } +const DOCS_COMPONENTS_DIR = join(process.cwd(), 'apps/docs/content/docs/components'); +const DOCS_CONTENT_DIR = join(process.cwd(), 'apps/docs/content/docs'); +const DOCS_INDEX_PATH = join(DOCS_CONTENT_DIR, 'index.mdx'); +const GETTING_STARTED_PATH = join(DOCS_CONTENT_DIR, 'getting-started.mdx'); +const RECIPES_LAYERS_PATH = join( + process.cwd(), + 'packages/@luke-ui/react/src/recipes/layers.css.ts', +); +const RECIPES_INDEX_PATH = join(process.cwd(), 'packages/@luke-ui/react/src/recipes/index.ts'); + function addComponentToDocsMeta(answers: GeneratorAnswers): string { const group = resolveGroup(answers); const componentDir = resolveComponentDir(answers); - const groupDir = join(process.cwd(), 'apps/docs/content/docs/components', group); + const groupDir = join(DOCS_COMPONENTS_DIR, group); const groupMetaPath = join(groupDir, 'meta.json'); mkdirSync(groupDir, { recursive: true }); @@ -93,10 +126,12 @@ function addComponentToDocsMeta(answers: GeneratorAnswers): string { return `Docs meta already contains ${componentDir}`; } - meta.pages = [...pages, componentDir].sort(); + pages.push(componentDir); + pages.sort(); + meta.pages = pages; writeFileSync(groupMetaPath, `${JSON.stringify(meta, null, '\t')}\n`); - const parentMetaPath = join(process.cwd(), 'apps/docs/content/docs/components/meta.json'); + const parentMetaPath = join(DOCS_CONTENT_DIR, 'meta.json'); const parentContent = readFileSync(parentMetaPath, 'utf8'); const parentMeta = JSON.parse(parentContent) as { pages?: Array; @@ -104,7 +139,9 @@ function addComponentToDocsMeta(answers: GeneratorAnswers): string { }; const parentPages = Array.isArray(parentMeta.pages) ? parentMeta.pages : []; if (!parentPages.includes(group)) { - parentMeta.pages = [...parentPages, group].sort(); + parentPages.push(group); + parentPages.sort(); + parentMeta.pages = parentPages; writeFileSync(parentMetaPath, `${JSON.stringify(parentMeta, null, '\t')}\n`); } @@ -114,85 +151,95 @@ function addComponentToDocsMeta(answers: GeneratorAnswers): string { function addComponentToDocsIndex(answers: GeneratorAnswers): string { const group = resolveGroup(answers); const componentDir = resolveComponentDir(answers); - const filePath = join(process.cwd(), 'apps/docs/content/docs/index.mdx'); const href = `/docs/components/${group}/${componentDir}`; const cardLine = `\t`; - const content = readFileSync(filePath, 'utf8'); + const content = readFileSync(DOCS_INDEX_PATH, 'utf8'); if (content.includes(`href="${href}"`)) { return `Docs index already links ${componentDir}`; } const marker = ''; if (!content.includes(marker)) { - throw new Error(`Could not find "${marker}" in ${filePath}`); + throw new Error(`Could not find "${marker}" in ${DOCS_INDEX_PATH}`); } const updatedContent = content.replace(marker, `${cardLine}\n${marker}`); - writeFileSync(filePath, updatedContent); + writeFileSync(DOCS_INDEX_PATH, updatedContent); return `Added docs index card for ${componentDir}`; } function addComponentToGettingStarted(answers: GeneratorAnswers): string { const group = resolveGroup(answers); const componentDir = resolveComponentDir(answers); - const filePath = join(process.cwd(), 'apps/docs/content/docs/getting-started.mdx'); const displayName = toDisplayName(componentDir); const nextStepLine = `- Read the [${displayName}](/docs/components/${group}/${componentDir}) docs.`; - const content = readFileSync(filePath, 'utf8'); + const content = readFileSync(GETTING_STARTED_PATH, 'utf8'); if (content.includes(nextStepLine)) { return `Getting Started already links ${componentDir}`; } const lines = content.split('\n'); - const nextStepsIndex = lines.findIndex((line) => line.trim() === '## Next Steps'); + let nextStepsIndex = -1; + for (let i = 0; i < lines.length; i += 1) { + if (lines[i]?.trim() === '## Next Steps') { + nextStepsIndex = i; + break; + } + } if (nextStepsIndex === -1) { - const appended = `${content.trimEnd()}\n\n## Next Steps\n\n${nextStepLine}\n`; - writeFileSync(filePath, appended); + const appended = `${content.replace(TRAILING_WHITESPACE_RE, '')}\n\n## Next Steps\n\n${nextStepLine}\n`; + writeFileSync(GETTING_STARTED_PATH, appended); return `Added Next Steps section with ${componentDir}`; } let insertAt = lines.length; for (let i = nextStepsIndex + 1; i < lines.length; i += 1) { - if (lines[i]?.startsWith('## ')) { + const line = lines[i]; + if (line && line.charAt(0) === '#' && line.charAt(1) === '#' && line.charAt(2) === ' ') { insertAt = i; break; } } lines.splice(insertAt, 0, nextStepLine); - writeFileSync(filePath, `${lines.join('\n').replace(/\n*$/, '\n')}`); + writeFileSync(GETTING_STARTED_PATH, `${lines.join('\n').replace(TRAILING_NEWLINES_RE, '\n')}`); return `Added Getting Started next step for ${componentDir}`; } -const STYLES_PRIMITIVES_PATH = join( - process.cwd(), - 'packages/@luke-ui/react/src/styles/primitives.css.ts', -); -const RECIPES_INDEX_PATH = join(process.cwd(), 'packages/@luke-ui/react/src/recipes/index.ts'); - -/** Append new component CSS import to styles/primitives.css.ts and sort. */ +/** Append new component CSS import to recipes/layers.css.ts and sort. */ function addComponentToStylesIndex(answers: GeneratorAnswers): string { const componentDir = resolveComponentDir(answers); - const newImport = `import '../recipes/${componentDir}.css.js';`; - const content = readFileSync(STYLES_PRIMITIVES_PATH, 'utf8'); + const newImport = `import './${componentDir}.css.js';`; + const content = readFileSync(RECIPES_LAYERS_PATH, 'utf8'); if (content.includes(newImport)) { - return `Styles primitives barrel already contains ${componentDir} CSS`; + return `Recipes layers already contains ${componentDir} CSS`; } - const imports = Array.from( - new Set( - content - .split('\n') - .map((line) => line.trim()) - .filter(Boolean) - .concat(newImport), - ), - ); + + const seen = new Set(); + const imports: Array = []; + let start = 0; + + for (let i = 0; i <= content.length; i += 1) { + if (i === content.length || content.charAt(i) === '\n') { + const line = content.slice(start, i).trim(); + if (line && !seen.has(line)) { + seen.add(line); + imports.push(line); + } + start = i + 1; + } + } + + if (!seen.has(newImport)) { + imports.push(newImport); + } + imports.sort((a, b) => a.localeCompare(b)); - writeFileSync(STYLES_PRIMITIVES_PATH, `${imports.join('\n')}\n`); - return `Added ${componentDir} CSS to styles primitives barrel`; + writeFileSync(RECIPES_LAYERS_PATH, `${imports.join('\n')}\n`); + return `Added ${componentDir} CSS to recipes layers`; } function addComponentToRecipesIndex(answers: GeneratorAnswers): string { @@ -206,9 +253,27 @@ function addComponentToRecipesIndex(answers: GeneratorAnswers): string { if (content.includes(typeExport)) { return `Recipes index already contains ${componentDir}`; } - const firstExportIndex = content.search(/^export\s/m); + + let firstExportIndex = -1; + for (let i = 0; i < content.length - 6; i += 1) { + if ( + content.charAt(i) === 'e' && + content.charAt(i + 1) === 'x' && + content.charAt(i + 2) === 'p' && + content.charAt(i + 3) === 'o' && + content.charAt(i + 4) === 'r' && + content.charAt(i + 5) === 't' && + content.charAt(i + 6) === ' ' + ) { + if (i === 0 || content.charAt(i - 1) === '\n') { + firstExportIndex = i; + break; + } + } + } + if (firstExportIndex === -1) { - const separator = content.endsWith('\n') ? '' : '\n'; + const separator = content.charAt(content.length - 1) === '\n' ? '' : '\n'; writeFileSync(RECIPES_INDEX_PATH, `${content}${separator}${typeExport}\n${valueExport}\n`); return `Added ${componentDir} to recipes index`; } diff --git a/packages/turbo-generators/templates/component/component.tsx.hbs b/packages/turbo-generators/templates/component/component.tsx.hbs index 2dcda81b..882556be 100644 --- a/packages/turbo-generators/templates/component/component.tsx.hbs +++ b/packages/turbo-generators/templates/component/component.tsx.hbs @@ -1,5 +1,5 @@ import type { ComponentProps, JSX } from 'react'; -import type { DistributiveOmit } from '../types/index.js'; +import type { DistributiveOmit } from '../types/distributive-omit.js'; import { cx } from '../utils/index.js'; import * as styles from '../recipes/{{kebabCase name}}.css.js'; diff --git a/packages/turbo-generators/tsconfig.json b/packages/turbo-generators/tsconfig.json new file mode 100644 index 00000000..5108c4f7 --- /dev/null +++ b/packages/turbo-generators/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "lib": ["ES2022"], + "module": "NodeNext", + "moduleDetection": "force", + "moduleResolution": "NodeNext", + "noEmit": true, + "noUncheckedIndexedAccess": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "strict": true, + "target": "ES2022", + "types": ["node"] + }, + "include": ["**/*.ts"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f07f4b2c..82ac1758 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -83,6 +83,9 @@ importers: '@turbo/gen': specifier: ^2.9.6 version: 2.9.6(@types/node@25.6.0) + eslint-plugin-react-hooks: + specifier: ^7.1.1 + version: 7.1.1(eslint@10.3.0(jiti@2.6.1)) knip: specifier: ^6.6.3 version: 6.6.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) @@ -1016,6 +1019,36 @@ packages: cpu: [x64] os: [win32] + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.23.5': + resolution: {integrity: sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/config-helpers@0.5.5': + resolution: {integrity: sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/core@1.2.1': + resolution: {integrity: sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/object-schema@3.0.5': + resolution: {integrity: sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/plugin-kit@0.7.1': + resolution: {integrity: sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + '@floating-ui/core@1.7.5': resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} @@ -1045,6 +1078,26 @@ packages: tailwindcss: optional: true + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + '@img/colour@1.1.0': resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} @@ -3255,6 +3308,9 @@ packages: '@types/doctrine@0.0.9': resolution: {integrity: sha512-eOIHzCUSH7SMfonMG1LsC2f8vxBFtho6NGBznK41R84YzPuvSBzrhEps33IsQiOW9+VL6NQ9DbjQJznk/S4uRA==} + '@types/esrecurse@4.3.1': + resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} + '@types/estree-jsx@1.0.5': resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} @@ -3267,6 +3323,9 @@ packages: '@types/jsesc@2.5.1': resolution: {integrity: sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==} + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/mdast@4.0.4': resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} @@ -3427,6 +3486,9 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + ajv@8.18.0: resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} @@ -3827,6 +3889,9 @@ packages: resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} engines: {node: '>=4.0.0'} + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + deep-object-diff@1.1.9: resolution: {integrity: sha512-Rn+RuwkmkDwCi2/oXOFS9Gsr5lJZu/yTGpK7wAaAIE75CC+LCGEZHpY6VQJa/RoJcrmaA/docWJZvYohlNkWPA==} @@ -3977,15 +4042,63 @@ packages: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + escape-string-regexp@5.0.0: resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} engines: {node: '>=12'} + eslint-plugin-react-hooks@7.1.1: + resolution: {integrity: sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==} + engines: {node: '>=18'} + peerDependencies: + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0 + + eslint-scope@9.1.2: + resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@10.3.0: + resolution: {integrity: sha512-XbEXaRva5cF0ZQB8w6MluHA0kZZfV2DuCMJ3ozyEOHLwDpZX2Lmm/7Pp0xdJmI0GL1W05VH5VwIFHEm1Vcw2gw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@11.2.0: + resolution: {integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + esprima@4.0.1: resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} engines: {node: '>=4'} hasBin: true + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + estree-util-attach-comments@3.0.0: resolution: {integrity: sha512-cKUwm/HUcTDsYh/9FgnuFqpfquUbwIqwKM26BVCGDPVgvaCl/nDCCjUfiLlx6lsEZ3Z4RFxNbOQ60pkaEwFxGw==} @@ -4045,6 +4158,12 @@ packages: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-uri@3.1.0: resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} @@ -4066,6 +4185,10 @@ packages: fflate@0.8.2: resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} @@ -4078,6 +4201,10 @@ packages: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + flatted@3.4.2: resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} @@ -4251,6 +4378,10 @@ packages: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + glob@13.0.6: resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} engines: {node: 18 || 20 || >=22} @@ -4326,6 +4457,12 @@ packages: resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} hasBin: true + hermes-estree@0.25.1: + resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} + + hermes-parser@0.25.1: + resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + hookable@6.1.1: resolution: {integrity: sha512-U9LYDy1CwhMCnprUfeAZWZGByVbhd54hwepegYTK7Pi5NvqEj63ifz5z+xukznehT7i6NIZRu89Ay1AZmRsLEQ==} @@ -4367,6 +4504,10 @@ packages: resolution: {integrity: sha512-bDxwDdF04gm550DfZHgffvlX+9kUlcz32UD0AeBTmVPFiWkrexF2XVmiuFFbDhiFuP8fQkrkvI2KdSNPYWAXkQ==} engines: {node: '>=20.19.0'} + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + indent-string@4.0.0: resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} engines: {node: '>=8'} @@ -4501,9 +4642,18 @@ packages: engines: {node: '>=6'} hasBin: true + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + json5@2.2.3: resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} engines: {node: '>=6'} @@ -4512,11 +4662,18 @@ packages: jsonfile@4.0.0: resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + knip@6.6.3: resolution: {integrity: sha512-7HSf5bLx6r66+sjXwSvSiDEE9RjRzHuAkrEFLE6XXHqaPDY97tdzNvyRVF9DeusbiV72kStAFiNnhj72rxJNGQ==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + lightningcss-android-arm64@1.32.0: resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} engines: {node: '>= 12.0.0'} @@ -4906,6 +5063,9 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + negotiator@0.6.4: resolution: {integrity: sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==} engines: {node: '>= 0.6'} @@ -4996,6 +5156,10 @@ packages: openapi-typescript-helpers@0.1.0: resolution: {integrity: sha512-OKTGPthhivLw/fHz6c3OPtg72vi86qaMlqbJuVJ23qOvQ+53uw1n7HdmkJFibloF7QEjDrDkzJiOJuockM/ljw==} + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + outdent@0.5.0: resolution: {integrity: sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q==} @@ -5162,6 +5326,10 @@ packages: resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} engines: {node: ^10 || ^12 || >=14} + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + prettier@2.8.8: resolution: {integrity: sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==} engines: {node: '>=10.13.0'} @@ -5184,6 +5352,10 @@ packages: engines: {node: '>=18'} hasBin: true + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + quansync@0.2.11: resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} @@ -5729,6 +5901,10 @@ packages: resolution: {integrity: sha512-+v2QJey7ZUeUiuigkU+uFfklvNUyPI2VO2vBpMYJA+a1hKFLFiKtUYlRHdb3P9CrAvMzi0upbjI4WT+zKtqkBg==} hasBin: true + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + type-fest@2.19.0: resolution: {integrity: sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==} engines: {node: '>=12.20'} @@ -5888,6 +6064,9 @@ packages: update-check@1.5.4: resolution: {integrity: sha512-5YHsflzHP4t1G+8WGPlvKbJEbAJGCgw+Em+dGR1KmBUbr1J36SJBqlHLjR7oob7sco5hWHGQVcr9B2poIVDDTQ==} + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + use-callback-ref@1.3.3: resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} engines: {node: '>=10'} @@ -6108,6 +6287,10 @@ packages: resolution: {integrity: sha512-o0cyEG0e8GPzT4iGHphIOh0cJOV8fivsXxddQasHPHfoZf1ZexrfeA21w2NaEN1RHE+fXlfISmOE8R9N3u3Qig==} engines: {node: '>=12'} + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + wrap-ansi@6.2.0: resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} engines: {node: '>=8'} @@ -6172,6 +6355,12 @@ packages: resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==} engines: {node: '>=18'} + zod-validation-error@4.0.2: + resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} @@ -6822,6 +7011,36 @@ snapshots: '@esbuild/win32-x64@0.28.0': optional: true + '@eslint-community/eslint-utils@4.9.1(eslint@10.3.0(jiti@2.6.1))': + dependencies: + eslint: 10.3.0(jiti@2.6.1) + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.23.5': + dependencies: + '@eslint/object-schema': 3.0.5 + debug: 4.4.3 + minimatch: 10.2.4 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.5.5': + dependencies: + '@eslint/core': 1.2.1 + + '@eslint/core@1.2.1': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/object-schema@3.0.5': {} + + '@eslint/plugin-kit@0.7.1': + dependencies: + '@eslint/core': 1.2.1 + levn: 0.4.1 + '@floating-ui/core@1.7.5': dependencies: '@floating-ui/utils': 0.2.11 @@ -6851,6 +7070,22 @@ snapshots: optionalDependencies: tailwindcss: 4.2.4 + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + + '@humanfs/types@0.15.0': {} + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + '@img/colour@1.1.0': {} '@img/sharp-darwin-arm64@0.34.5': @@ -8658,6 +8893,8 @@ snapshots: '@types/doctrine@0.0.9': {} + '@types/esrecurse@4.3.1': {} + '@types/estree-jsx@1.0.5': dependencies: '@types/estree': 1.0.8 @@ -8670,6 +8907,8 @@ snapshots: '@types/jsesc@2.5.1': {} + '@types/json-schema@7.0.15': {} + '@types/mdast@4.0.4': dependencies: '@types/unist': 3.0.3 @@ -8935,6 +9174,13 @@ snapshots: acorn@8.16.0: {} + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + ajv@8.18.0: dependencies: fast-deep-equal: 3.1.3 @@ -9285,6 +9531,8 @@ snapshots: deep-extend@0.6.0: {} + deep-is@0.1.4: {} + deep-object-diff@1.1.9: {} deepmerge@4.3.1: {} @@ -9487,10 +9735,87 @@ snapshots: escalade@3.2.0: {} + escape-string-regexp@4.0.0: {} + escape-string-regexp@5.0.0: {} + eslint-plugin-react-hooks@7.1.1(eslint@10.3.0(jiti@2.6.1)): + dependencies: + '@babel/core': 7.29.0 + '@babel/parser': 7.29.0 + eslint: 10.3.0(jiti@2.6.1) + hermes-parser: 0.25.1 + zod: 4.3.6 + zod-validation-error: 4.0.2(zod@4.3.6) + transitivePeerDependencies: + - supports-color + + eslint-scope@9.1.2: + dependencies: + '@types/esrecurse': 4.3.1 + '@types/estree': 1.0.8 + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@10.3.0(jiti@2.6.1): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.3.0(jiti@2.6.1)) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.23.5 + '@eslint/config-helpers': 0.5.5 + '@eslint/core': 1.2.1 + '@eslint/plugin-kit': 0.7.1 + '@humanfs/node': 0.16.8 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.8 + ajv: 6.15.0 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 9.1.2 + eslint-visitor-keys: 5.0.1 + espree: 11.2.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + minimatch: 10.2.4 + natural-compare: 1.4.0 + optionator: 0.9.4 + optionalDependencies: + jiti: 2.6.1 + transitivePeerDependencies: + - supports-color + + espree@11.2.0: + dependencies: + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) + eslint-visitor-keys: 5.0.1 + esprima@4.0.1: {} + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + estree-util-attach-comments@3.0.0: dependencies: '@types/estree': 1.0.8 @@ -9567,6 +9892,10 @@ snapshots: merge2: 1.4.1 micromatch: 4.0.8 + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + fast-uri@3.1.0: {} fastq@1.20.1: @@ -9587,6 +9916,10 @@ snapshots: fflate@0.8.2: {} + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 @@ -9601,6 +9934,11 @@ snapshots: locate-path: 6.0.0 path-exists: 4.0.0 + flat-cache@4.0.1: + dependencies: + flatted: 3.4.2 + keyv: 4.5.4 + flatted@3.4.2: {} formatly@0.3.0: @@ -9759,6 +10097,10 @@ snapshots: dependencies: is-glob: 4.0.3 + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + glob@13.0.6: dependencies: minimatch: 10.2.4 @@ -9912,6 +10254,12 @@ snapshots: he@1.2.0: {} + hermes-estree@0.25.1: {} + + hermes-parser@0.25.1: + dependencies: + hermes-estree: 0.25.1 + hookable@6.1.1: {} html-escaper@2.0.2: {} @@ -9943,6 +10291,8 @@ snapshots: import-without-cache@0.3.3: {} + imurmurhash@0.1.4: {} + indent-string@4.0.0: {} ini@1.3.8: {} @@ -10042,14 +10392,24 @@ snapshots: jsesc@3.1.0: {} + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + json-schema-traverse@1.0.0: {} + json-stable-stringify-without-jsonify@1.0.1: {} + json5@2.2.3: {} jsonfile@4.0.0: optionalDependencies: graceful-fs: 4.2.11 + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + knip@6.6.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0): dependencies: fdir: 6.5.0(picomatch@4.0.4) @@ -10070,6 +10430,11 @@ snapshots: - '@emnapi/core' - '@emnapi/runtime' + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + lightningcss-android-arm64@1.32.0: optional: true @@ -10674,6 +11039,8 @@ snapshots: nanoid@3.3.11: {} + natural-compare@1.4.0: {} + negotiator@0.6.4: {} negotiator@1.0.0: {} @@ -10786,6 +11153,15 @@ snapshots: openapi-typescript-helpers@0.1.0: {} + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + outdent@0.5.0: {} oxc-minify@0.111.0: @@ -11060,6 +11436,8 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + prelude-ls@1.2.1: {} + prettier@2.8.8: {} prettier@3.8.1: {} @@ -11079,6 +11457,8 @@ snapshots: picocolors: 1.1.1 sade: 1.8.1 + punycode@2.3.1: {} + quansync@0.2.11: {} quansync@1.0.0: {} @@ -11744,6 +12124,10 @@ snapshots: '@turbo/windows-64': 2.9.6 '@turbo/windows-arm64': 2.9.6 + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + type-fest@2.19.0: {} typescript@5.6.1-rc: {} @@ -11840,6 +12224,10 @@ snapshots: registry-auth-token: 3.3.2 registry-url: 3.1.0 + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + use-callback-ref@1.3.3(@types/react@19.2.14)(react@19.2.5): dependencies: react: 19.2.5 @@ -12001,6 +12389,8 @@ snapshots: dependencies: string-width: 5.1.2 + word-wrap@1.2.5: {} + wrap-ansi@6.2.0: dependencies: ansi-styles: 4.3.0 @@ -12056,6 +12446,10 @@ snapshots: yoctocolors-cjs@2.1.3: {} + zod-validation-error@4.0.2(zod@4.3.6): + dependencies: + zod: 4.3.6 + zod@3.25.76: {} zod@4.3.6: {} -- 2.51.2