diff --git a/packages/lytenyte-ui-react/LICENSE b/packages/lytenyte-ui-react/LICENSE new file mode 100644 index 000000000..52a87aa62 --- /dev/null +++ b/packages/lytenyte-ui-react/LICENSE @@ -0,0 +1,13 @@ +Copyright 2025 1771 Technologies + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/packages/lytenyte-ui-react/package.json b/packages/lytenyte-ui-react/package.json new file mode 100644 index 000000000..f2e273df4 --- /dev/null +++ b/packages/lytenyte-ui-react/package.json @@ -0,0 +1,47 @@ +{ + "name": "@1771technologies/lytenyte-ui-react", + "description": "LyteNyte React component library", + "license": "Apache-2.0", + "keywords": [ + "TODO" + ], + "version": "0.0.1", + "type": "module", + "files": [ + "dist", + "LICENSE" + ], + "homepage": "https://1771technologies.com", + "repository": { + "type": "git", + "url": "https://github.com/1771-technologies/lytenyte.git", + "directory": "packages/lytenyte-ui-react" + }, + "bugs": { + "url": "https://github.com/1771-technologies/lytenyte/issues" + }, + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts", + "require": "./src/index.ts" + } + }, + "publishConfig": { + "access": "public", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.js" + } + } + }, + "dependencies": { + "@1771technologies/lytenyte-design": "workspace:*" + }, + "peerDependencies": { + "react": "18.0.0 || ^19.0.0", + "react-dom": "18.0.0 || ^19.0.0" + } +} diff --git a/packages/lytenyte-ui-react/src/accordion/accordion-context.tsx b/packages/lytenyte-ui-react/src/accordion/accordion-context.tsx new file mode 100644 index 000000000..9ecc1dc72 --- /dev/null +++ b/packages/lytenyte-ui-react/src/accordion/accordion-context.tsx @@ -0,0 +1,18 @@ +import { createContext, useContext } from "react"; + +export interface AccordionContextValue { + readonly rootId: string; + readonly collapsible: boolean; + readonly hiddenUntilFound: boolean; + readonly keepMounted: boolean; + readonly disabled: boolean; + readonly value: any[]; + readonly attrs: Record; + readonly orientation: "vertical" | "horizontal"; + readonly onValueChange: (v: any) => void; +} + +const context = createContext({} as AccordionContextValue); + +export const AccordionRootProvider = context.Provider; +export const useAccordionRoot = () => useContext(context); diff --git a/packages/lytenyte-ui-react/src/accordion/accordion-header.tsx b/packages/lytenyte-ui-react/src/accordion/accordion-header.tsx new file mode 100644 index 000000000..7144b7401 --- /dev/null +++ b/packages/lytenyte-ui-react/src/accordion/accordion-header.tsx @@ -0,0 +1,28 @@ +import { forwardRef } from "react"; +import { useSlot } from "../hooks/use-slot.js"; +import { useAccordionItem } from "./accordion-item-context.js"; +import { useClassName } from "../hooks/use-class-name.js"; +import { useStyle } from "../hooks/use-style.js"; +import type { Accordion } from "./accordion"; +import { DATA_ACCORDION_HEADER } from "../constants.js"; + +function AccordionHeaderBase( + { render, style: providedStyle, className: providedClassName, ...props }: Accordion.Header.Props, + ref: Accordion.Header.Props["ref"], +) { + const ctx = useAccordionItem(); + + const className = useClassName(providedClassName, ctx.state); + const style = useStyle(providedStyle, ctx.state); + + const slot = useSlot({ + slot: render ??

, + ref, + props: [props, { className, style, [DATA_ACCORDION_HEADER]: "", ...ctx.attrs }], + state: ctx.state, + }); + + return slot; +} + +export const AccordionHeader = forwardRef(AccordionHeaderBase); diff --git a/packages/lytenyte-ui-react/src/accordion/accordion-item-context.tsx b/packages/lytenyte-ui-react/src/accordion/accordion-item-context.tsx new file mode 100644 index 000000000..c23b7ef88 --- /dev/null +++ b/packages/lytenyte-ui-react/src/accordion/accordion-item-context.tsx @@ -0,0 +1,20 @@ +import type { Dispatch, SetStateAction } from "react"; +import { createContext, useContext } from "react"; +import type { Accordion } from "./accordion.js"; + +export interface AccordionItemContext { + readonly collapsible: boolean; + readonly hiddenUntilFound: boolean; + readonly attrs: Record; + readonly state: Accordion.Item.State; + readonly toggle: () => void; + readonly triggerId: string; + readonly setTriggerId: Dispatch>; + readonly panelId: string; + readonly setPanelId: Dispatch>; + readonly onOpenChangeComplete?: (open: boolean) => void; +} + +const context = createContext({} as AccordionItemContext); +export const AccordionItemProvider = context.Provider; +export const useAccordionItem = () => useContext(context); diff --git a/packages/lytenyte-ui-react/src/accordion/accordion-item.tsx b/packages/lytenyte-ui-react/src/accordion/accordion-item.tsx new file mode 100644 index 000000000..30876a4d4 --- /dev/null +++ b/packages/lytenyte-ui-react/src/accordion/accordion-item.tsx @@ -0,0 +1,106 @@ +import { forwardRef, useId, useMemo, useState, type ReactElement } from "react"; +import { useSlot } from "../hooks/use-slot.js"; +import type { AccordionItemContext } from "./accordion-item-context"; +import { AccordionItemProvider } from "./accordion-item-context.js"; +import { useAccordionRoot } from "./accordion-context.js"; +import { useClassName } from "../hooks/use-class-name.js"; +import { useStyle } from "../hooks/use-style.js"; +import type { Accordion } from "./accordion.js"; +import { useEvent } from "../hooks/use-event.js"; +import { DATA_ACCORDION_ITEM, DATA_CLOSED, DATA_DISABLED, DATA_OPEN } from "../constants.js"; + +function AccordionItemBase( + { + value: providedValue, + collapsible, + onOpenChange, + onOpenChangeComplete, + disabled: providedDisabled, + style: providedStyle, + className: providedClassName, + render, + ...props + }: Accordion.Item.Props, + ref: Accordion.Item.Props["ref"], +) { + const fallbackValue = useId(); + const value = providedValue ?? fallbackValue; + + const [triggerId, setTriggerId] = useState(useId()); + const [panelId, setPanelId] = useState(useId()); + + const root = useAccordionRoot(); + + const disabled = providedDisabled ?? root.disabled; + + const state = useMemo(() => { + const open = root.value.includes(value); + + return { + disabled, + orientation: root.orientation, + value: root.value, + open, + }; + }, [disabled, root.orientation, root.value, value]); + + const className = useClassName(providedClassName, state); + const style = useStyle(providedStyle, state); + + const attrs = useMemo(() => { + return { + ...root.attrs, + [DATA_CLOSED]: !state.open || undefined, + [DATA_OPEN]: state.open || undefined, + [DATA_DISABLED]: disabled || undefined, + }; + }, [disabled, root.attrs, state.open]); + + const toggle = useEvent(function toggle() { + if (disabled) return; + + const isOpen = root.value.includes(value); + if (isOpen && !(collapsible ?? root.collapsible)) return; + + root.onValueChange(value); + onOpenChange?.(!isOpen); + }); + + const slot = useSlot({ + slot: render ??
, + props: [props, { className, style, [DATA_ACCORDION_ITEM]: "", ...attrs, onClick: () => {} }], + ref: ref, + state, + }); + + const contextValue = useMemo(() => { + return { + collapsible: collapsible ?? root.collapsible, + hiddenUntilFound: root.hiddenUntilFound, + state, + attrs, + toggle, + triggerId, + setTriggerId, + panelId, + setPanelId, + onOpenChangeComplete, + }; + }, [ + attrs, + collapsible, + onOpenChangeComplete, + panelId, + root.collapsible, + root.hiddenUntilFound, + state, + toggle, + triggerId, + ]); + + return {slot}; +} + +export const AccordionItem = forwardRef(AccordionItemBase) as ( + props: Accordion.Item.Props, +) => ReactElement; diff --git a/packages/lytenyte-ui-react/src/accordion/accordion-panel.tsx b/packages/lytenyte-ui-react/src/accordion/accordion-panel.tsx new file mode 100644 index 000000000..c4f6f0bb1 --- /dev/null +++ b/packages/lytenyte-ui-react/src/accordion/accordion-panel.tsx @@ -0,0 +1,108 @@ +import { forwardRef, useCallback, useEffect, useRef } from "react"; +import { useAccordionItem } from "./accordion-item-context.js"; +import { useClassName } from "../hooks/use-class-name.js"; +import { useStyle } from "../hooks/use-style.js"; +import { useSlot } from "../hooks/use-slot.js"; +import type { Accordion } from "./accordion"; +import { useAccordionRoot } from "./accordion-context.js"; +import { useIsoEffect } from "../hooks/use-iso-effect.js"; +import { useTransitionedOpen, type TransitionStatus } from "../hooks/use-transition-status.js"; +import { useCombinedRefs } from "../hooks/use-combined-ref.js"; +import { CSS_PANEL_HEIGHT, CSS_PANEL_WIDTH, DATA_ACCORDION_PANEL } from "../constants.js"; + +function AccordionPanelBase( + { + render, + style: providedStyle, + className: providedClassName, + keepMounted: providedKeepMounted, + hiddenUntilFound: providedHiddenUntilFound, + ...props + }: Accordion.Panel.Props, + ref: Accordion.Panel.Props["ref"], +) { + const ctx = useAccordionItem(); + const rootCtx = useAccordionRoot(); + const isHiddenUntilFound = providedHiddenUntilFound ?? rootCtx.hiddenUntilFound; + + const onStatusChange = useCallback( + (next: TransitionStatus, _prev: TransitionStatus, el: HTMLElement) => { + if (next === "idle") { + el.style.setProperty(CSS_PANEL_HEIGHT, "auto"); + el.style.setProperty(CSS_PANEL_WIDTH, "auto"); + ctx.onOpenChangeComplete?.(true); + } else if (next === "closed") { + ctx.onOpenChangeComplete?.(false); + if (isHiddenUntilFound) { + el.setAttribute("hidden", "until-found"); + } + } else { + if (next === "start" && isHiddenUntilFound) { + el.removeAttribute("hidden"); + } + el.style.setProperty(CSS_PANEL_HEIGHT, `${el.scrollHeight}px`); + el.style.setProperty(CSS_PANEL_WIDTH, `${el.scrollWidth}px`); + } + }, + [ctx, isHiddenUntilFound], + ); + + const { mounted, ref: transitionRef } = useTransitionedOpen(ctx.state.open, onStatusChange); + + const panelRef = useRef(null); + + // Set hidden="until-found" before first paint for initially-closed panels. + // Subsequent open/close transitions are handled by onStatusChange. + useIsoEffect(() => { + const el = panelRef.current; + if (!el) return; + if (isHiddenUntilFound && !ctx.state.open) { + el.setAttribute("hidden", "until-found"); + } else { + el.removeAttribute("hidden"); + } + }, [isHiddenUntilFound]); + + useEffect(() => { + if (!isHiddenUntilFound) return; + const el = panelRef.current; + if (!el) return; + + const onBeforeMatch = () => { + if (!ctx.state.open) ctx.toggle(); + }; + + el.addEventListener("beforematch", onBeforeMatch); + return () => el.removeEventListener("beforematch", onBeforeMatch); + }, [isHiddenUntilFound, ctx.state.open, ctx.toggle, ctx]); + + useIsoEffect(() => { + if (!props.id) return; + ctx.setPanelId(props.id); + }, [props.id]); + + const className = useClassName(providedClassName, ctx.state); + const style = useStyle(providedStyle, ctx.state); + + const slot = useSlot({ + slot: render ??
, + ref: useCombinedRefs(ref, transitionRef, panelRef), + props: [ + { + id: ctx.panelId, + role: "region", + "aria-labelledby": ctx.triggerId, + }, + props, + { className, style, [DATA_ACCORDION_PANEL]: "", ...ctx.attrs }, + ], + state: ctx.state, + }); + + const keepMounted = isHiddenUntilFound || providedKeepMounted || rootCtx.keepMounted; + if (!mounted && !keepMounted) return null; + + return slot; +} + +export const AccordionPanel = forwardRef(AccordionPanelBase); diff --git a/packages/lytenyte-ui-react/src/accordion/accordion-root.tsx b/packages/lytenyte-ui-react/src/accordion/accordion-root.tsx new file mode 100644 index 000000000..a7618aefa --- /dev/null +++ b/packages/lytenyte-ui-react/src/accordion/accordion-root.tsx @@ -0,0 +1,162 @@ +import type { JSX } from "react"; +import { forwardRef, useId, useMemo, type KeyboardEvent } from "react"; +import { useSlot } from "../hooks/use-slot.js"; +import { useControlled } from "../hooks/use-controlled.js"; +import type { AccordionContextValue } from "./accordion-context.js"; +import { AccordionRootProvider } from "./accordion-context.js"; +import { useClassName } from "../hooks/use-class-name.js"; +import { useStyle } from "../hooks/use-style.js"; +import type { Accordion } from "./accordion.js"; +import { useEvent } from "../hooks/use-event.js"; +import { DATA_ACCORDION_ROOT, DATA_DISABLED, DATA_ORIENTATION, DATA_ROOT_ID } from "../constants.js"; +import { useDirection } from "../direction-provider/direction-provider.js"; + +function AccordionRootImpl( + { + render, + collapsible, + value: providedValue, + defaultValue, + hiddenUntilFound, + disabled: providedDisabled, + multiple, + loopFocus, + orientation: providedOrientation, + style: providedStyle, + className: providedClassName, + keepMounted, + onValueChange: providedValueChange, + ...props + }: Accordion.Props, + forwarded: Accordion.Props["ref"], +) { + const rootId = useId(); + const [value, setValue] = useControlled({ + controlled: providedValue, + default: defaultValue ?? [], + }); + const disabled = providedDisabled ?? false; + const orientation = providedOrientation ?? "vertical"; + + const onValueChange = useEvent(function onValueChange(v: T) { + if (multiple) { + const next = value.includes(v) ? value.filter((x) => x != v) : [...value, v]; + + setValue(next); + providedValueChange?.(next); + } else { + const next = value.includes(v) ? [] : [v]; + + setValue(next); + providedValueChange?.(next); + } + }); + + const state = useMemo(() => { + return { value, disabled, orientation }; + }, [value, disabled, orientation]); + + const className = useClassName(providedClassName, state); + const style = useStyle(providedStyle, state); + + const sharedAttrs = useMemo(() => { + return { + [DATA_ORIENTATION]: providedOrientation, + [DATA_DISABLED]: disabled || undefined, + }; + }, [disabled, providedOrientation]); + + const direction = useDirection(); + + const handleKeyDown = useEvent((e: KeyboardEvent) => { + const isVertical = orientation === "vertical"; + + let move: "next" | "prev" | "first" | "last" | null = null; + + if (isVertical) { + if (e.key === "ArrowDown") move = "next"; + if (e.key === "ArrowUp") move = "prev"; + } else { + if (e.key === "ArrowRight") move = direction === "rtl" ? "prev" : "next"; + if (e.key === "ArrowLeft") move = direction === "rtl" ? "next" : "prev"; + } + + if (e.key === "Home") move = "first"; + if (e.key === "End") move = "last"; + + if (!move) return; + + const rootEl = e.currentTarget; + const selector = `[${DATA_ROOT_ID}="${rootId}"]`; + const triggers = Array.from(rootEl.querySelectorAll(selector)); + + if (triggers.length === 0) return; + + const active = (e.target as HTMLElement).closest?.(selector); + const currentIndex = active ? triggers.indexOf(active as HTMLElement) : -1; + if (currentIndex === -1) return; // Focus is not a trigger, so we should do nothing. + + e.preventDefault(); + + const loop = loopFocus ?? true; + let nextIndex: number; + + switch (move) { + case "next": + nextIndex = currentIndex + 1; + if (nextIndex >= triggers.length) nextIndex = loop ? 0 : triggers.length - 1; + break; + case "prev": + nextIndex = currentIndex - 1; + if (nextIndex < 0) nextIndex = loop ? triggers.length - 1 : 0; + break; + case "first": + nextIndex = 0; + break; + case "last": + nextIndex = triggers.length - 1; + break; + } + + triggers[nextIndex]?.focus(); + }); + + const slot = useSlot>({ + slot: render ??
, + props: [ + { dir: direction, onKeyDown: handleKeyDown } satisfies JSX.IntrinsicElements["div"], + props, + { className, style, [DATA_ACCORDION_ROOT]: "", ...sharedAttrs }, + ], + ref: forwarded, + state: state, + }); + + const contextValue = useMemo(() => { + return { + rootId, + collapsible: collapsible ?? true, + disabled: disabled, + hiddenUntilFound: hiddenUntilFound ?? false, + keepMounted: keepMounted ?? false, + value, + attrs: sharedAttrs, + orientation: orientation, + onValueChange, + }; + }, [ + rootId, + collapsible, + disabled, + hiddenUntilFound, + keepMounted, + onValueChange, + orientation, + sharedAttrs, + value, + ]); + + return {slot}; +} + +export const AccordionRoot = forwardRef(AccordionRootImpl); diff --git a/packages/lytenyte-ui-react/src/accordion/accordion-trigger.tsx b/packages/lytenyte-ui-react/src/accordion/accordion-trigger.tsx new file mode 100644 index 000000000..ee9ce7da2 --- /dev/null +++ b/packages/lytenyte-ui-react/src/accordion/accordion-trigger.tsx @@ -0,0 +1,53 @@ +import type { JSX } from "react"; +import { forwardRef } from "react"; +import { useAccordionItem } from "./accordion-item-context.js"; +import { useAccordionRoot } from "./accordion-context.js"; +import { useClassName } from "../hooks/use-class-name.js"; +import { useStyle } from "../hooks/use-style.js"; +import { useSlot } from "../hooks/use-slot.js"; +import type { Accordion } from "./accordion"; +import { useIsoEffect } from "../hooks/use-iso-effect.js"; +import { DATA_ACCORDION_TRIGGER, DATA_ROOT_ID } from "../constants.js"; + +function AccordionTriggerBase( + { render, style: providedStyle, className: providedClassName, ...props }: Accordion.Trigger.Props, + ref: Accordion.Trigger.Props["ref"], +) { + const ctx = useAccordionItem(); + const rootCtx = useAccordionRoot(); + + const className = useClassName(providedClassName, ctx.state); + const style = useStyle(providedStyle, ctx.state); + + useIsoEffect(() => { + if (!props.id) return; + ctx.setTriggerId(props.id); + }, [props.id]); + + const slot = useSlot({ + slot: render ??