diff --git a/.manifest.json b/.manifest.json new file mode 100644 index 0000000..8fefc28 --- /dev/null +++ b/.manifest.json @@ -0,0 +1,979 @@ +{ + "resolved": [ + { + "tag": "md-accordion", + "name": "MdAccordion", + "file": "expansion/accordion.js", + "className": "MdAccordion", + "events": {} + }, + { + "tag": "md-assist-chip", + "name": "MdAssistChip", + "file": "chips/assist-chip.js", + "className": "MdAssistChip", + "events": {} + }, + { + "tag": "md-autocomplete", + "name": "MdAutocomplete", + "file": "autocomplete/autocomplete.js", + "className": "MdAutocomplete", + "events": { + "onInput": "input", + "onSelect": "autocomplete:select" + } + }, + { + "tag": "md-badge", + "name": "MdBadge", + "file": "badge/badge.js", + "className": "MdBadge", + "events": {} + }, + { + "tag": "md-bottom-app-bar", + "name": "MdBottomAppBar", + "file": "appbar/bottom-app-bar.js", + "className": "MdBottomAppBar", + "events": {} + }, + { + "tag": "md-bottom-sheet", + "name": "MdBottomSheet", + "file": "sheet/bottom-sheet.js", + "className": "MdBottomSheet", + "events": { + "onOpening": "bottom-sheet:opening", + "onOpened": "bottom-sheet:opened", + "onClosing": "bottom-sheet:closing", + "onClosed": "bottom-sheet:closed" + } + }, + { + "tag": "md-branded-fab", + "name": "MdBrandedFab", + "file": "fab/branded-fab.js", + "className": "MdBrandedFab", + "events": {} + }, + { + "tag": "md-button", + "name": "MdButton", + "file": "labs/gb/components/button/md-button.js", + "className": "Button", + "events": {} + }, + { + "tag": "md-button-group", + "name": "MdButtonGroup", + "file": "buttongroup/button-group.js", + "className": "MdButtonGroup", + "events": { + "onChange": "button-group:change" + } + }, + { + "tag": "md-card", + "name": "MdCard", + "file": "card/card.js", + "className": "MdCard", + "events": {} + }, + { + "tag": "md-carousel", + "name": "MdCarousel", + "file": "carousel/carousel.js", + "className": "MdCarousel", + "events": { + "onChange": "carousel-change" + } + }, + { + "tag": "md-carousel-item", + "name": "MdCarouselItem", + "file": "carousel/carousel-item.js", + "className": "MdCarouselItem", + "events": {} + }, + { + "tag": "md-checkbox", + "name": "MdCheckbox", + "file": "checkbox/checkbox.js", + "className": "MdCheckbox", + "events": { + "onChange": "change", + "onInput": "input" + } + }, + { + "tag": "md-chip-set", + "name": "MdChipSet", + "file": "chips/chip-set.js", + "className": "MdChipSet", + "events": {} + }, + { + "tag": "md-circular-progress", + "name": "MdCircularProgress", + "file": "progress/circular-progress.js", + "className": "MdCircularProgress", + "events": {} + }, + { + "tag": "md-date-picker", + "name": "MdDatePicker", + "file": "datepicker/date-picker.js", + "className": "MdDatePicker", + "events": { + "onChange": "date-picker:change" + } + }, + { + "tag": "md-dialog", + "name": "MdDialog", + "file": "dialog/dialog.js", + "className": "MdDialog", + "events": { + "onOpen": "open", + "onOpened": "opened", + "onClose": "close", + "onClosed": "closed", + "onCancel": "cancel" + } + }, + { + "tag": "md-divider", + "name": "MdDivider", + "file": "divider/divider.js", + "className": "MdDivider", + "events": {} + }, + { + "tag": "md-elevated-button", + "name": "MdElevatedButton", + "file": "button/elevated-button.js", + "className": "MdElevatedButton", + "events": {} + }, + { + "tag": "md-elevated-card", + "name": "MdElevatedCard", + "file": "labs/card/elevated-card.js", + "className": "MdElevatedCard", + "events": {} + }, + { + "tag": "md-elevation", + "name": "MdElevation", + "file": "elevation/elevation.js", + "className": "MdElevation", + "events": {} + }, + { + "tag": "md-expansion-panel", + "name": "MdExpansionPanel", + "file": "expansion/expansion-panel.js", + "className": "MdExpansionPanel", + "events": { + "onToggle": "expansion:toggle" + } + }, + { + "tag": "md-fab", + "name": "MdFab", + "file": "fab/fab.js", + "className": "MdFab", + "events": {} + }, + { + "tag": "md-fab-menu", + "name": "MdFabMenu", + "file": "fabmenu/fab-menu.js", + "className": "MdFabMenu", + "events": { + "onOpen": "fab-menu:open", + "onClose": "fab-menu:close" + } + }, + { + "tag": "md-fab-menu-item", + "name": "MdFabMenuItem", + "file": "fabmenu/fab-menu-item.js", + "className": "MdFabMenuItem", + "events": { + "onClick": "fab-menu-item:click" + } + }, + { + "tag": "md-filled-button", + "name": "MdFilledButton", + "file": "button/filled-button.js", + "className": "MdFilledButton", + "events": {} + }, + { + "tag": "md-filled-card", + "name": "MdFilledCard", + "file": "labs/card/filled-card.js", + "className": "MdFilledCard", + "events": {} + }, + { + "tag": "md-filled-field", + "name": "MdFilledField", + "file": "field/filled-field.js", + "className": "MdFilledField", + "events": {} + }, + { + "tag": "md-filled-icon-button", + "name": "MdFilledIconButton", + "file": "iconbutton/filled-icon-button.js", + "className": "MdFilledIconButton", + "events": {} + }, + { + "tag": "md-filled-select", + "name": "MdFilledSelect", + "file": "select/filled-select.js", + "className": "MdFilledSelect", + "events": { + "onChange": "change", + "onInput": "input", + "onOpening": "opening", + "onOpened": "opened", + "onClosing": "closing", + "onClosed": "closed" + } + }, + { + "tag": "md-filled-text-field", + "name": "MdFilledTextField", + "file": "textfield/filled-text-field.js", + "className": "MdFilledTextField", + "events": { + "onChange": "change", + "onInput": "input" + } + }, + { + "tag": "md-filled-tonal-button", + "name": "MdFilledTonalButton", + "file": "button/filled-tonal-button.js", + "className": "MdFilledTonalButton", + "events": {} + }, + { + "tag": "md-filled-tonal-icon-button", + "name": "MdFilledTonalIconButton", + "file": "iconbutton/filled-tonal-icon-button.js", + "className": "MdFilledTonalIconButton", + "events": {} + }, + { + "tag": "md-filter-chip", + "name": "MdFilterChip", + "file": "chips/filter-chip.js", + "className": "MdFilterChip", + "events": {} + }, + { + "tag": "md-focus-ring", + "name": "MdFocusRing", + "file": "focus/md-focus-ring.js", + "className": "MdFocusRing", + "events": {} + }, + { + "tag": "md-grid-list", + "name": "MdGridList", + "file": "gridlist/grid-list.js", + "className": "MdGridList", + "events": {} + }, + { + "tag": "md-grid-tile", + "name": "MdGridTile", + "file": "gridlist/grid-tile.js", + "className": "MdGridTile", + "events": {} + }, + { + "tag": "md-icon", + "name": "MdIcon", + "file": "icon/icon.js", + "className": "MdIcon", + "events": {} + }, + { + "tag": "md-icon-button", + "name": "MdIconButton", + "file": "iconbutton/icon-button.js", + "className": "MdIconButton", + "events": {} + }, + { + "tag": "md-input-chip", + "name": "MdInputChip", + "file": "chips/input-chip.js", + "className": "MdInputChip", + "events": { + "onRemove": "remove", + "onUpdateFocus": "update-focus" + } + }, + { + "tag": "md-item", + "name": "MdItem", + "file": "labs/item/item.js", + "className": "MdItem", + "events": {} + }, + { + "tag": "md-linear-progress", + "name": "MdLinearProgress", + "file": "progress/linear-progress.js", + "className": "MdLinearProgress", + "events": {} + }, + { + "tag": "md-list", + "name": "MdList", + "file": "list/list.js", + "className": "MdList", + "events": {} + }, + { + "tag": "md-list-detail", + "name": "MdListDetail", + "file": "layout/md-list-detail.js", + "className": "MdListDetail", + "events": {} + }, + { + "tag": "md-list-item", + "name": "MdListItem", + "file": "list/list-item.js", + "className": "MdListItem", + "events": {} + }, + { + "tag": "md-loading-indicator", + "name": "MdLoadingIndicator", + "file": "loadingindicator/loading-indicator.js", + "className": "MdLoadingIndicator", + "events": {} + }, + { + "tag": "md-menu", + "name": "MdMenu", + "file": "menu/menu.js", + "className": "MdMenu", + "events": { + "onOpening": "opening", + "onOpened": "opened", + "onClosing": "closing", + "onClosed": "closed" + } + }, + { + "tag": "md-menu-group", + "name": "MdMenuGroup", + "file": "labs/gb/components/menu/md-menu-group.js", + "className": "MenuGroup", + "events": {} + }, + { + "tag": "md-menu-item", + "name": "MdMenuItem", + "file": "menu/menu-item.js", + "className": "MdMenuItem", + "events": { + "onCloseMenu": "close-menu" + } + }, + { + "tag": "md-navigation-bar", + "name": "MdNavigationBar", + "file": "labs/navigationbar/navigation-bar.js", + "className": "MdNavigationBar", + "events": { + "onActivated": "navigation-bar-activated" + } + }, + { + "tag": "md-navigation-drawer", + "name": "MdNavigationDrawer", + "file": "labs/navigationdrawer/navigation-drawer.js", + "className": "MdNavigationDrawer", + "events": { + "onNavigationDrawerChanged": "navigation-drawer-changed" + } + }, + { + "tag": "md-navigation-drawer-modal", + "name": "MdNavigationDrawerModal", + "file": "labs/navigationdrawer/navigation-drawer-modal.js", + "className": "MdNavigationDrawerModal", + "events": { + "onNavigationDrawerChanged": "navigation-drawer-changed" + } + }, + { + "tag": "md-navigation-rail", + "name": "MdNavigationRail", + "file": "navigationrail/navigation-rail.js", + "className": "MdNavigationRail", + "events": { + "onChange": "navigation-rail:change", + "onItemActivate": "navigation-rail-item:activate" + } + }, + { + "tag": "md-navigation-rail-item", + "name": "MdNavigationRailItem", + "file": "navigationrail/navigation-rail-item.js", + "className": "MdNavigationRailItem", + "events": {} + }, + { + "tag": "md-navigation-tab", + "name": "MdNavigationTab", + "file": "labs/navigationtab/navigation-tab.js", + "className": "MdNavigationTab", + "events": {} + }, + { + "tag": "md-outlined-button", + "name": "MdOutlinedButton", + "file": "button/outlined-button.js", + "className": "MdOutlinedButton", + "events": {} + }, + { + "tag": "md-outlined-card", + "name": "MdOutlinedCard", + "file": "labs/card/outlined-card.js", + "className": "MdOutlinedCard", + "events": {} + }, + { + "tag": "md-outlined-field", + "name": "MdOutlinedField", + "file": "field/outlined-field.js", + "className": "MdOutlinedField", + "events": {} + }, + { + "tag": "md-outlined-icon-button", + "name": "MdOutlinedIconButton", + "file": "iconbutton/outlined-icon-button.js", + "className": "MdOutlinedIconButton", + "events": {} + }, + { + "tag": "md-outlined-segmented-button", + "name": "MdOutlinedSegmentedButton", + "file": "labs/segmentedbutton/outlined-segmented-button.js", + "className": "MdOutlinedSegmentedButton", + "events": {} + }, + { + "tag": "md-outlined-segmented-button-set", + "name": "MdOutlinedSegmentedButtonSet", + "file": "labs/segmentedbuttonset/outlined-segmented-button-set.js", + "className": "MdOutlinedSegmentedButtonSet", + "events": {} + }, + { + "tag": "md-outlined-select", + "name": "MdOutlinedSelect", + "file": "select/outlined-select.js", + "className": "MdOutlinedSelect", + "events": { + "onChange": "change", + "onInput": "input", + "onOpening": "opening", + "onOpened": "opened", + "onClosing": "closing", + "onClosed": "closed" + } + }, + { + "tag": "md-outlined-text-field", + "name": "MdOutlinedTextField", + "file": "textfield/outlined-text-field.js", + "className": "MdOutlinedTextField", + "events": { + "onChange": "change", + "onInput": "input" + } + }, + { + "tag": "md-paginator", + "name": "MdPaginator", + "file": "paginator/paginator.js", + "className": "MdPaginator", + "events": { + "onPage": "paginator:page" + } + }, + { + "tag": "md-pane", + "name": "MdPane", + "file": "layout/md-pane.js", + "className": "MdPane", + "events": {} + }, + { + "tag": "md-primary-tab", + "name": "MdPrimaryTab", + "file": "tabs/primary-tab.js", + "className": "MdPrimaryTab", + "events": {} + }, + { + "tag": "md-radio", + "name": "MdRadio", + "file": "radio/radio.js", + "className": "MdRadio", + "events": { + "onChange": "change", + "onInput": "input" + } + }, + { + "tag": "md-ripple", + "name": "MdRipple", + "file": "ripple/ripple.js", + "className": "MdRipple", + "events": {} + }, + { + "tag": "md-scaffold", + "name": "MdScaffold", + "file": "layout/md-scaffold.js", + "className": "MdScaffold", + "events": {} + }, + { + "tag": "md-search-bar", + "name": "MdSearchBar", + "file": "search/search-bar.js", + "className": "MdSearchBar", + "events": { + "onInput": "input", + "onSearch": "search", + "onSearchOpen": "search:open", + "onSearchClose": "search:close" + } + }, + { + "tag": "md-secondary-tab", + "name": "MdSecondaryTab", + "file": "tabs/secondary-tab.js", + "className": "MdSecondaryTab", + "events": {} + }, + { + "tag": "md-select-option", + "name": "MdSelectOption", + "file": "select/select-option.js", + "className": "MdSelectOption", + "events": {} + }, + { + "tag": "md-side-sheet", + "name": "MdSideSheet", + "file": "sheet/side-sheet.js", + "className": "MdSideSheet", + "events": { + "onOpening": "side-sheet:opening", + "onOpened": "side-sheet:opened", + "onClosing": "side-sheet:closing", + "onClosed": "side-sheet:closed" + } + }, + { + "tag": "md-slider", + "name": "MdSlider", + "file": "slider/slider.js", + "className": "MdSlider", + "events": { + "onChange": "change", + "onInput": "input" + } + }, + { + "tag": "md-snackbar", + "name": "MdSnackbar", + "file": "snackbar/snackbar.js", + "className": "MdSnackbar", + "events": { + "onOpening": "opening", + "onOpened": "opened", + "onClosing": "closing", + "onClosed": "closed" + } + }, + { + "tag": "md-split-button", + "name": "MdSplitButton", + "file": "labs/gb/components/splitbutton/md-split-button.js", + "className": "SplitButton", + "events": {} + }, + { + "tag": "md-step", + "name": "MdStep", + "file": "stepper/step.js", + "className": "MdStep", + "events": {} + }, + { + "tag": "md-stepper", + "name": "MdStepper", + "file": "stepper/stepper.js", + "className": "MdStepper", + "events": { + "onChange": "stepper:change" + } + }, + { + "tag": "md-sub-menu", + "name": "MdSubMenu", + "file": "menu/sub-menu.js", + "className": "MdSubMenu", + "events": { + "onOpening": "opening", + "onOpened": "opened", + "onClosing": "closing", + "onClosed": "closed" + } + }, + { + "tag": "md-suggestion-chip", + "name": "MdSuggestionChip", + "file": "chips/suggestion-chip.js", + "className": "MdSuggestionChip", + "events": {} + }, + { + "tag": "md-supporting-pane", + "name": "MdSupportingPane", + "file": "layout/md-supporting-pane.js", + "className": "MdSupportingPane", + "events": {} + }, + { + "tag": "md-switch", + "name": "MdSwitch", + "file": "switch/switch.js", + "className": "MdSwitch", + "events": { + "onChange": "change", + "onInput": "input" + } + }, + { + "tag": "md-table", + "name": "MdTable", + "file": "table/table.js", + "className": "MdTable", + "events": { + "onSort": "table:sort", + "onSelectionChange": "table:selection-change" + } + }, + { + "tag": "md-tabs", + "name": "MdTabs", + "file": "tabs/tabs.js", + "className": "MdTabs", + "events": { + "onChange": "change" + } + }, + { + "tag": "md-text-button", + "name": "MdTextButton", + "file": "button/text-button.js", + "className": "MdTextButton", + "events": {} + }, + { + "tag": "md-time-picker", + "name": "MdTimePicker", + "file": "timepicker/time-picker.js", + "className": "MdTimePicker", + "events": { + "onChange": "time-picker:change" + } + }, + { + "tag": "md-toolbar", + "name": "MdToolbar", + "file": "toolbar/md-toolbar.js", + "className": "MdToolbar", + "events": {} + }, + { + "tag": "md-tooltip", + "name": "MdTooltip", + "file": "tooltip/tooltip.js", + "className": "MdTooltip", + "events": { + "onShow": "tooltip-show", + "onHide": "tooltip-hide" + } + }, + { + "tag": "md-top-app-bar", + "name": "MdTopAppBar", + "file": "appbar/top-app-bar.js", + "className": "MdTopAppBar", + "events": {} + }, + { + "tag": "md-tree", + "name": "MdTree", + "file": "tree/tree.js", + "className": "MdTree", + "events": { + "onSelect": "tree:select" + } + }, + { + "tag": "md-tree-item", + "name": "MdTreeItem", + "file": "tree/tree-item.js", + "className": "MdTreeItem", + "events": { + "onToggle": "tree-item:toggle", + "onActivate": "tree-item:activate" + } + }, + { + "tag": "md-virtual-scroller", + "name": "MdVirtualScroller", + "file": "virtualscroll/virtual-scroller.js", + "className": "MdVirtualScroller", + "events": { + "onRange": "virtual-scroll:range" + } + }, + { + "tag": "md-webgpu-canvas", + "name": "MdWebgpuCanvas", + "file": "effects/webgpu-canvas.js", + "className": "MdWebgpuCanvas", + "events": {} + }, + { + "tag": "md-avatar", + "name": "MdAvatar", + "file": "avatar/avatar.js", + "className": "MdAvatar", + "events": { + "onError": "error" + } + }, + { + "tag": "md-avatar-group", + "name": "MdAvatarGroup", + "file": "avatar/avatar-group.js", + "className": "MdAvatarGroup", + "events": {} + }, + { + "tag": "md-alert", + "name": "MdAlert", + "file": "alert/alert.js", + "className": "MdAlert", + "events": { + "onClose": "close" + } + }, + { + "tag": "md-skeleton", + "name": "MdSkeleton", + "file": "skeleton/skeleton.js", + "className": "MdSkeleton", + "events": {} + }, + { + "tag": "md-breadcrumbs", + "name": "MdBreadcrumbs", + "file": "breadcrumbs/breadcrumbs.js", + "className": "MdBreadcrumbs", + "events": {} + }, + { + "tag": "md-link", + "name": "MdLink", + "file": "link/link.js", + "className": "MdLink", + "events": {} + }, + { + "tag": "md-surface", + "name": "MdSurface", + "file": "surface/surface.js", + "className": "MdSurface", + "events": {} + }, + { + "tag": "md-backdrop", + "name": "MdBackdrop", + "file": "backdrop/backdrop.js", + "className": "MdBackdrop", + "events": {} + }, + { + "tag": "md-popover", + "name": "MdPopover", + "file": "popover/popover.js", + "className": "MdPopover", + "events": { + "onOpened": "opened", + "onClosed": "closed" + } + }, + { + "tag": "md-rating", + "name": "MdRating", + "file": "rating/rating.js", + "className": "MdRating", + "events": { + "onChange": "change", + "onInput": "input" + } + }, + { + "tag": "md-line-chart", + "name": "MdLineChart", + "file": "charts/line-chart.js", + "className": "MdLineChart", + "events": {} + }, + { + "tag": "md-bar-chart", + "name": "MdBarChart", + "file": "charts/bar-chart.js", + "className": "MdBarChart", + "events": {} + }, + { + "tag": "md-pie-chart", + "name": "MdPieChart", + "file": "charts/pie-chart.js", + "className": "MdPieChart", + "events": {} + }, + { + "tag": "md-area-chart", + "name": "MdAreaChart", + "file": "charts/area-chart.js", + "className": "MdAreaChart", + "events": {} + }, + { + "tag": "md-sparkline", + "name": "MdSparkline", + "file": "charts/sparkline.js", + "className": "MdSparkline", + "events": {} + }, + { + "tag": "md-gauge", + "name": "MdGauge", + "file": "charts/gauge.js", + "className": "MdGauge", + "events": {} + }, + { + "tag": "md-date-range-picker", + "name": "MdDateRangePicker", + "file": "datepicker/date-range-picker.js", + "className": "MdDateRangePicker", + "events": { + "onInput": "input", + "onChange": "change", + "onDateRangeChange": "date-range-picker:change" + } + }, + { + "tag": "md-mobile-stepper", + "name": "MdMobileStepper", + "file": "mobilestepper/mobile-stepper.js", + "className": "MdMobileStepper", + "events": {} + }, + { + "tag": "md-scatter-chart", + "name": "MdScatterChart", + "file": "charts/scatter-chart.js", + "className": "MdScatterChart", + "events": {} + }, + { + "tag": "md-radar-chart", + "name": "MdRadarChart", + "file": "charts/radar-chart.js", + "className": "MdRadarChart", + "events": {} + }, + { + "tag": "md-date-time-picker", + "name": "MdDateTimePicker", + "file": "datepicker/date-time-picker.js", + "className": "MdDateTimePicker", + "events": { + "onInput": "input", + "onChange": "change", + "onDateTimeChange": "date-time-picker:change" + } + }, + { + "tag": "md-scheduler", + "name": "MdScheduler", + "file": "scheduler/scheduler.js", + "className": "MdScheduler", + "events": { + "onNavigate": "scheduler:navigate", + "onSelect": "scheduler:select", + "onEventClick": "scheduler:event-click" + } + }, + { + "tag": "md-type", + "name": "MdType", + "file": "typography/md-type.js", + "className": "MdType", + "events": {} + }, + { + "tag": "md-3d-canvas", + "name": "Md3dCanvas", + "file": "canvas3d/canvas-3d.js", + "className": "M3Canvas3D", + "events": {} + }, + { + "tag": "md-3d-globe", + "name": "Md3dGlobe", + "file": "globe3d/globe-3d.js", + "className": "M3Globe3D", + "events": {} + }, + { + "tag": "md-3d-card", + "name": "Md3dCard", + "file": "card3d/card-3d.js", + "className": "M3Card3D", + "events": {} + } + ], + "unresolved": [] +} \ No newline at end of file diff --git a/FRAMEWORKS.md b/FRAMEWORKS.md new file mode 100644 index 0000000..a12434a --- /dev/null +++ b/FRAMEWORKS.md @@ -0,0 +1,204 @@ +# Intégration frameworks (SSR / RSC) — `@aphrody/m3-react` + +Guide d'intégration des wrappers Material 3 dans un framework React à rendu +serveur — **Next.js App Router**, Remix, ou tout setup RSC / SSR / hydration. +Les composants sont des **web components Lit**, donc **client-only** : ils ne +rendent rien côté serveur (le shadow DOM n'existe qu'au navigateur) et +s'« upgradent » après l'hydration. Les exemples ci-dessous utilisent Next.js App +Router (le cas le plus contraint), mais les principes valent pour tout SSR. + +L'exemple du monorepo ([`examples/showcase/`](../../docs/products/examples/showcase)) est +bun-natif (pas Next) ; ce guide couvre les spécificités **Next.js App Router** +côté consommateur. Le package se consomme tel quel dans une app Next. + +--- + +## 1. Ce que le package livre + +Le package est **pré-buildé** dans `dist/` (`bun run build`, via `tsup` + `tsc`) : + +- **ESM** (`dist/index.js`, `dist/transitions/index.js`) — pas de `.ts` brut, donc + consommable depuis `node_modules` **sans `transpilePackages`**. +- Chaque chunk `.js` porte la directive **`'use client';`** en tête → Next RSC + traite tout le package comme des **client components**, sans erreur + « You're importing a component that needs … ». +- **Types** (`dist/**/*.d.ts`). +- `react`, `react-dom` et `@material/web` sont **externes** (peer deps / résolus + chez le consommateur). Les imports profonds `@material/web//.js` sont + conservés tels quels : c'est l'import du wrapper qui déclenche le + `customElements.define()` (effet de bord) côté client. + +`package.json` pointe `main`/`module`/`types`/`exports` sur `dist/`, déclare +`sideEffects` (les imports d'éléments enregistrent les custom elements — ne pas +tree-shaker) et `files: ["dist"]`. + +```jsonc +"exports": { + ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" }, + "./transitions":{ "types": "./dist/transitions/index.d.ts", "default": "./dist/transitions/index.js" } +} +``` + +--- + +## 2. Boundaries client / serveur + +Règle : **tout ce qui touche un `Md…` vit dans un fichier `'use client'`.** + +- `layout.tsx` / `page.tsx` restent des **Server Components** (pas de directive) ; + ils importent le CSS de thème et rendent un **îlot client**. +- L'îlot client (ex. `demo.tsx`) porte `'use client'`, importe les wrappers, + gère state/refs/handlers. + +```tsx +// app/page.tsx — Server Component +import { Demo } from "./demo"; +export default function Home() { + return ( +
+ +
+ ); +} +``` + +```tsx +// app/demo.tsx — Client island +"use client"; +import { + MdFilledButton, + MdOutlinedTextField, + MdDialog, + MdLineChart, + MdTabs, + MdPrimaryTab, +} from "@aphrody/m3-react"; +import { Fade } from "@aphrody/m3-react/transitions"; +import { useRef, useState } from "react"; + +export function Demo() { + const dialogRef = useRef>(null); + const [name, setName] = useState(""); + return ( + <> + dialogRef.current?.show()}>Ouvrir + setName((e.target as HTMLInputElement).value)} + /> + + A + B + + + +
Titre
+
Ouvert via ref impérative.
+
+ dialogRef.current?.close()}>Fermer +
+
+ + ); +} +``` + +### Refs impératives (dialog, etc.) + +Le `ref` d'un wrapper pointe sur **l'instance du custom element** (avec ses +méthodes `show()` / `close()` / `returnValue`…). Pour typer le ref sans importer +les sources de `@material/web`, dérivez-le du composant : + +```tsx +const dialogRef = useRef>(null); +dialogRef.current?.show(); +``` + +### Propriétés objet (charts, etc.) + +`@lit/react` passe les props objet/tableau comme **propriétés** de l'élément +(pas des attributs). `` règle donc +`.series` / `.categories` directement — pas de JSON à sérialiser. + +--- + +## 3. SSR / hydration + parade FOUC + +Côté serveur, un `` se rend en élément non-`:defined` (pas de +shadow DOM). **Aucun crash SSR** : la page se pré-rend en statique (vérifié, +`next build` → route `/` en `○ Static`). Pas de hydration mismatch tant que le +markup serveur = markup client (c'est le cas, le wrapper rend juste la balise). + +Pour éviter le **flash of unstyled content** (l'élément brut visible avant +l'upgrade), masquez les éléments tant qu'ils ne sont pas définis : + +```css +/* app/theme.css — FOUC parade */ +:not(:defined) { + visibility: hidden; +} +``` + +Optionnellement, révélez après upgrade complet via `customElements.whenDefined` +dans un `useEffect` (cf. `examples/showcase`). + +### Tokens de thème + +Définissez les `--md-sys-*` au niveau `:root` (ou `body`) dans un CSS importé par +le layout serveur : + +```css +:root { + --md-sys-color-primary: #6750a4; + --md-sys-color-on-primary: #fff; + /* … générés depuis @aphrody/m3-tokens en prod … */ +} +``` + +--- + +## 4. Fallback : consommer les **sources** (`transpilePackages`) + +Le dist pré-buildé fonctionne sans config. Si vous préférez consommer les +**sources TS** du package (ou épingler `@material/web` sur ses sources `.ts`, +p. ex. en monorepo lié par symlink), activez `transpilePackages` pour que Next +les compile : + +```ts +// next.config.ts +import type { NextConfig } from "next"; +const nextConfig: NextConfig = { + transpilePackages: ["@aphrody/m3-react", "@material/web"], +}; +export default nextConfig; +``` + +> En monorepo, le lien workspace expose aussi les `.ts` de `@material/web` à côté +> des `.js`/`.d.ts`. Le `moduleResolution: "bundler"` de TS résout alors `…/x.js` +> vers la **source `.ts`** (décorateurs → erreur de typecheck). Deux parades : +> soit `transpilePackages` ci-dessus, soit ne typer que les artefacts publiés +> (l'app d'exemple redirige `@material/web/*` vers une copie `.js`+`.d.ts`-only +> via `compilerOptions.paths`, exactement ce qu'un `npm install` produirait). + +--- + +## 5. Checklist build + +```bash +# 1. builder la lib + le package react (turbo) +bun run build # à la racine du monorepo → @material/web .js + dist react + +# 2. dans VOTRE app Next.js : importer les wrappers (déjà marqués 'use client') +# import { MdFilledButton } from "@aphrody/m3-react"; +# next build → vert, sans transpilePackages +``` + +> Le monorepo n'embarque plus d'exemple Next.js dédié : l'exemple de référence +> est `examples/showcase` (bun-natif). Ce guide reste valable pour intégrer les +> wrappers dans **votre** app Next.js App Router. diff --git a/README.md b/README.md index 4295e45..915d171 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,327 @@ -# aphrody-m3-react -Aphrody package @aphrody/m3-react + + +# `@aphrody/m3-react` + +Wrappers React **par élément `md-*`** du fork aphrody `@material/web` (v2.4.1, 119 éléments), +générés avec [`@lit/react`](https://www.npmjs.com/package/@lit/react) `createComponent`. + +Ce package réalise le plan que le fork prévoit (`apps/m3-react`) mais ne livre pas sur disque +(cf. `migration/00-CONVENTIONS.md` §0). Il expose un composant React typé pour chacun des +**119 tags** listés dans `migration/scripts/md-elements.txt`. + +- **Statut compilation : 119 / 119 wrappers générés, `tsc --noEmit` → 0 erreur.** +- 1 wrapper React par élément `md-*`, nom = PascalCase du tag (CONVENTIONS §2). +- Events natifs réels mappés par élément (vérifiés dans le code Lit, voir plus bas). + +## Install + +> **bun uniquement** (jamais npm/pnpm — CONVENTIONS §7.1). + +Le package est déjà installé dans ce dossier (`migration/wrappers/`) : + +```bash +cd migration/wrappers +bun install # @lit/react + @material/web (file:../../material-web) + react/react-dom +bun run typecheck # tsc --noEmit → 0 erreur +bun run generate # régénère wrappers/ + index.ts depuis md-elements.txt +``` + +Dans un projet consommateur : + +```jsonc +// package.json +{ + "dependencies": { + "@aphrody/m3-react": "file:../migration/wrappers", + "react": "^19", + "react-dom": "^19", + }, +} +``` + +## Usage + +Importer un wrapper enregistre l'élément custom correspondant **comme effet de bord** +(le wrapper importe le module `@material/web/...` qui appelle `customElements.define`). +Aucun import side-effect supplémentaire n'est nécessaire. + +```tsx +import { MdFilledButton, MdOutlinedTextField, MdCheckbox } from "@aphrody/m3-react"; + +function LoginForm() { + const [email, setEmail] = React.useState(""); + const [accept, setAccept] = React.useState(false); + + return ( +
+ {/* controlled : la signature React change — e.target.value, PAS (e, value) */} + setEmail((e.target as HTMLInputElement).value)} + /> + + + + console.log({ email, accept })}>Valider + + ); +} +``` + +### Slots & icônes (CONVENTIONS §4) + +Les sous-composants MUI (`DialogTitle`, `CardHeader`, `startIcon`…) deviennent du contenu slotté : + +```tsx +import { MdDialog, MdFilledButton, MdTextButton, MdIcon } from "@aphrody/m3-react"; + + console.log((e.target as any).returnValue)}> +
Supprimer ?
+
+ Cette action est irréversible. +
+
+ Annuler + + delete + Supprimer + +
+
; +``` + +> **`MdIcon` exige la police Material Symbols.** La lib ne la charge pas. Une fois, +> au démarrage de l'app : +> +> ```ts +> import { ensureMaterialSymbols } from "@aphrody/material-web/icon/material-symbols.js"; +> ensureMaterialSymbols(); // ou { iconNames: ["delete", "search", …] } pour subseter +> ``` +> +> Le texte enfant est le **nom du glyphe** (snake_case Material Symbols). Les axes +> variables se pilotent par token : `--md-icon-fill` (0..1), `--md-icon-wght` +> (100..700), `--md-icon-grad`, `--md-icon-opsz`. Migration depuis +> `@mui/icons-material` : codemod `migration/codemods/transforms/icons.ts` (96 % auto). + +## Architecture + +- `index.ts` — réexporte les 119 wrappers (un `export *` par catégorie). +- `wrappers/.ts` — 45 fichiers groupés par dossier source de `material-web/` + (`button.ts`, `select.ts`, `labs.ts`, …). Chaque export : + + ```ts + export const MdFilledButton = createComponent({ + react: React, + tagName: "md-filled-button", + elementClass: MdFilledButtonElement, // importé depuis @material/web/button/filled-button.js + events: {}, // events natifs réels (voir tableau) + }); + ``` + +- `generate.ts` — générateur **bun** : lit `md-elements.txt`, scanne `material-web/` pour + résoudre chaque tag → `{fichier d'entrée, classe exportée}` via le motif + `@customElement('md-x') export class Foo`, applique la table d'events vérifiés, et émet + les fichiers. Préfère le chemin canonique (bundlé par `all.ts` / `aphrody-components.ts` / + `aphrody-labs.ts`), puis non-`labs/gb`, sinon `labs/gb` (pour les tags `md-button`, + `md-card`, `md-menu-group`, `md-split-button` qui n'existent que là). +- `material-web-shims.d.ts` — voir « Détail compilation ». + +## Événements mappés (vérifiés dans le code Lit) + +Noms d'events extraits par grep de `@fires` / `new (Custom)?Event('…')` dans +`material-web//internal/*.ts`. Les composants **upstream** utilisent des noms simples +(`input`, `change`, `opening`…) ; les composants **fork** namespacent les leurs +(`paginator:page`, `table:sort`, `side-sheet:opening`, `tree:select`…). + +| Élément | Events React → event natif | +| --------------------------------------------------------------- | ------------------------------------------------------------------------- | +| `MdCheckbox`, `MdRadio`, `MdSwitch`, `MdSlider`, `Md*TextField` | `onChange:change`, `onInput:input` | +| `MdFilledSelect`, `MdOutlinedSelect` | `+onOpening/onOpened/onClosing/onClosed` | +| `MdDialog` | `onOpen:open`, `onOpened`, `onClose:close`, `onClosed`, `onCancel:cancel` | +| `MdMenu`, `MdSubMenu` | `onOpening/onOpened/onClosing/onClosed` | +| `MdMenuItem` | `onCloseMenu:close-menu` | +| `MdSnackbar` | `onOpening/onOpened/onClosing/onClosed` (bare) | +| `MdBottomSheet` / `MdSideSheet` | `onOpening:bottom-sheet:opening` … (fork, namespacés) | +| `MdSearchBar` | `onInput`, `onSearch:search`, `onSearchOpen/onSearchClose` | +| `MdAutocomplete` | `onInput`, `onSelect:autocomplete:select` | +| `MdNavigationBar` | `onActivated:navigation-bar-activated` | +| `MdNavigationRail` | `onChange:navigation-rail:change`, `onItemActivate` | +| `MdNavigationDrawer(Modal)` | `onNavigationDrawerChanged:navigation-drawer-changed` | +| `MdInputChip` | `onRemove:remove`, `onUpdateFocus:update-focus` | +| `MdButtonGroup` | `onChange:button-group:change` | +| `MdFabMenu` / `MdFabMenuItem` | `onOpen/onClose:fab-menu:*`, `onClick:fab-menu-item:click` | +| `MdExpansionPanel` | `onToggle:expansion:toggle` | +| `MdStepper` | `onChange:stepper:change` | +| `MdDatePicker` / `MdTimePicker` | `onChange:date-picker:change` / `time-picker:change` | +| `MdTable` | `onSort:table:sort`, `onSelectionChange:table:selection-change` | +| `MdPaginator` | `onPage:paginator:page` | +| `MdTree` / `MdTreeItem` | `onSelect:tree:select` / `onToggle:tree-item:toggle`, `onActivate` | +| `MdVirtualScroller` | `onRange:virtual-scroll:range` | +| `MdTooltip` | `onShow:tooltip-show`, `onHide:tooltip-hide` | +| `MdTabs` | `onChange:change` | + +Les éléments purement présentationnels (`md-icon`, `md-divider`, `md-elevation`, `md-ripple`, +`md-focus-ring`, chips assist/filter/suggestion, cards, fab, boutons, `md-item`, `md-toolbar`, +`md-carousel`, `md-accordion`, `md-loading-indicator`, panes/scaffold…) n'émettent pas d'event +documenté : `events: {}`. + +## Détail compilation + +`tsc --noEmit` compile **toute la chaîne** (les wrappers _et_ les sources `.ts` de +`material-web/` qu'ils importent). Trois ajustements ont été nécessaires côté `tsconfig.json` / +shims, **sans toucher** à `material-web/src` : + +1. **`experimentalDecorators: true` + `useDefineForClassFields: false`** — les éléments Lit du + fork utilisent les décorateurs _expérimentaux_ (`@property()`, `@customElement()`). +2. **`material-web-shims.d.ts`** — déclare `*.cssresult.js` et `*.css` comme modules ambiants. + Ces fichiers de styles sont des **artefacts du build SASS** (`build:sass` → `build:css-to-ts`) + **absents de ce checkout source** du fork. Le wrapper React ne touche jamais ces valeurs + (les styles voyagent avec l'élément au runtime), d'où un typage `any` — qui évite aussi le + conflit nominal entre le `lit@2.8.0` imbriqué de `material-web` et le `lit@3.3.3` du package. +3. **`moduleResolution: bundler`** — résout les imports bare `@material/web/x/y.js` vers les + sources `.ts` (le fork n'a pas de `.js`/`.d.ts` compilés ni de champ `exports`). + +> En contexte de **build réel** du fork (`material-web` buildé : `*.cssresult.js` et `*.css` +> présents), les shims deviennent inutiles ; ils ne servent qu'à faire compiler le wrapper +> contre les **sources** du fork tel que livré ici. + +### Blocages réels + +**Aucun.** Les 119 éléments de `md-elements.txt` résolvent vers un fichier d'entrée réel avec +classe exportée, et compilent. Les 4 tags `md-button`, `md-card`, `md-menu-group`, +`md-split-button` n'existent que sous `labs/gb/components/` (composants « Google Sans » du fork) : +leurs classes exportées ne suivent pas la convention `Md*` (`Button`, `Card`, `MenuGroup`, +`SplitButton`) — le wrapper les ré-alias correctement (`MdButton`, `MdCard`, …). + +## Liste complète des wrappers (119) + +> Régénérée par `generate.ts` (`.manifest.json`). 0 non résolu(s). + +| Wrapper | Tag | Import élément | # events | +| --- | --- | --- | --- | +| `Md3dCanvas` | `md-3d-canvas` | `@aphrody/material-web/canvas3d/canvas-3d.js` | — | +| `Md3dCard` | `md-3d-card` | `@aphrody/material-web/card3d/card-3d.js` | — | +| `Md3dGlobe` | `md-3d-globe` | `@aphrody/material-web/globe3d/globe-3d.js` | — | +| `MdAccordion` | `md-accordion` | `@aphrody/material-web/expansion/accordion.js` | — | +| `MdAlert` | `md-alert` | `@aphrody/material-web/alert/alert.js` | 1 | +| `MdAreaChart` | `md-area-chart` | `@aphrody/material-web/charts/area-chart.js` | — | +| `MdAssistChip` | `md-assist-chip` | `@aphrody/material-web/chips/assist-chip.js` | — | +| `MdAutocomplete` | `md-autocomplete` | `@aphrody/material-web/autocomplete/autocomplete.js` | 2 | +| `MdAvatar` | `md-avatar` | `@aphrody/material-web/avatar/avatar.js` | 1 | +| `MdAvatarGroup` | `md-avatar-group` | `@aphrody/material-web/avatar/avatar-group.js` | — | +| `MdBackdrop` | `md-backdrop` | `@aphrody/material-web/backdrop/backdrop.js` | — | +| `MdBadge` | `md-badge` | `@aphrody/material-web/badge/badge.js` | — | +| `MdBarChart` | `md-bar-chart` | `@aphrody/material-web/charts/bar-chart.js` | — | +| `MdBottomAppBar` | `md-bottom-app-bar` | `@aphrody/material-web/appbar/bottom-app-bar.js` | — | +| `MdBottomSheet` | `md-bottom-sheet` | `@aphrody/material-web/sheet/bottom-sheet.js` | 4 | +| `MdBrandedFab` | `md-branded-fab` | `@aphrody/material-web/fab/branded-fab.js` | — | +| `MdBreadcrumbs` | `md-breadcrumbs` | `@aphrody/material-web/breadcrumbs/breadcrumbs.js` | — | +| `MdButton` | `md-button` | `@aphrody/material-web/labs/gb/components/button/md-button.js` | — | +| `MdButtonGroup` | `md-button-group` | `@aphrody/material-web/buttongroup/button-group.js` | 1 | +| `MdCard` | `md-card` | `@aphrody/material-web/card/card.js` | — | +| `MdCarousel` | `md-carousel` | `@aphrody/material-web/carousel/carousel.js` | 1 | +| `MdCarouselItem` | `md-carousel-item` | `@aphrody/material-web/carousel/carousel-item.js` | — | +| `MdCheckbox` | `md-checkbox` | `@aphrody/material-web/checkbox/checkbox.js` | 2 | +| `MdChipSet` | `md-chip-set` | `@aphrody/material-web/chips/chip-set.js` | — | +| `MdCircularProgress` | `md-circular-progress` | `@aphrody/material-web/progress/circular-progress.js` | — | +| `MdDatePicker` | `md-date-picker` | `@aphrody/material-web/datepicker/date-picker.js` | 1 | +| `MdDateRangePicker` | `md-date-range-picker` | `@aphrody/material-web/datepicker/date-range-picker.js` | 3 | +| `MdDateTimePicker` | `md-date-time-picker` | `@aphrody/material-web/datepicker/date-time-picker.js` | 3 | +| `MdDialog` | `md-dialog` | `@aphrody/material-web/dialog/dialog.js` | 5 | +| `MdDivider` | `md-divider` | `@aphrody/material-web/divider/divider.js` | — | +| `MdElevatedButton` | `md-elevated-button` | `@aphrody/material-web/button/elevated-button.js` | — | +| `MdElevatedCard` | `md-elevated-card` | `@aphrody/material-web/labs/card/elevated-card.js` | — | +| `MdElevation` | `md-elevation` | `@aphrody/material-web/elevation/elevation.js` | — | +| `MdExpansionPanel` | `md-expansion-panel` | `@aphrody/material-web/expansion/expansion-panel.js` | 1 | +| `MdFab` | `md-fab` | `@aphrody/material-web/fab/fab.js` | — | +| `MdFabMenu` | `md-fab-menu` | `@aphrody/material-web/fabmenu/fab-menu.js` | 2 | +| `MdFabMenuItem` | `md-fab-menu-item` | `@aphrody/material-web/fabmenu/fab-menu-item.js` | 1 | +| `MdFilledButton` | `md-filled-button` | `@aphrody/material-web/button/filled-button.js` | — | +| `MdFilledCard` | `md-filled-card` | `@aphrody/material-web/labs/card/filled-card.js` | — | +| `MdFilledField` | `md-filled-field` | `@aphrody/material-web/field/filled-field.js` | — | +| `MdFilledIconButton` | `md-filled-icon-button` | `@aphrody/material-web/iconbutton/filled-icon-button.js` | — | +| `MdFilledSelect` | `md-filled-select` | `@aphrody/material-web/select/filled-select.js` | 6 | +| `MdFilledTextField` | `md-filled-text-field` | `@aphrody/material-web/textfield/filled-text-field.js` | 2 | +| `MdFilledTonalButton` | `md-filled-tonal-button` | `@aphrody/material-web/button/filled-tonal-button.js` | — | +| `MdFilledTonalIconButton` | `md-filled-tonal-icon-button` | `@aphrody/material-web/iconbutton/filled-tonal-icon-button.js` | — | +| `MdFilterChip` | `md-filter-chip` | `@aphrody/material-web/chips/filter-chip.js` | — | +| `MdFocusRing` | `md-focus-ring` | `@aphrody/material-web/focus/md-focus-ring.js` | — | +| `MdGauge` | `md-gauge` | `@aphrody/material-web/charts/gauge.js` | — | +| `MdGridList` | `md-grid-list` | `@aphrody/material-web/gridlist/grid-list.js` | — | +| `MdGridTile` | `md-grid-tile` | `@aphrody/material-web/gridlist/grid-tile.js` | — | +| `MdIcon` | `md-icon` | `@aphrody/material-web/icon/icon.js` | — | +| `MdIconButton` | `md-icon-button` | `@aphrody/material-web/iconbutton/icon-button.js` | — | +| `MdInputChip` | `md-input-chip` | `@aphrody/material-web/chips/input-chip.js` | 2 | +| `MdItem` | `md-item` | `@aphrody/material-web/labs/item/item.js` | — | +| `MdLinearProgress` | `md-linear-progress` | `@aphrody/material-web/progress/linear-progress.js` | — | +| `MdLineChart` | `md-line-chart` | `@aphrody/material-web/charts/line-chart.js` | — | +| `MdLink` | `md-link` | `@aphrody/material-web/link/link.js` | — | +| `MdList` | `md-list` | `@aphrody/material-web/list/list.js` | — | +| `MdListDetail` | `md-list-detail` | `@aphrody/material-web/layout/md-list-detail.js` | — | +| `MdListItem` | `md-list-item` | `@aphrody/material-web/list/list-item.js` | — | +| `MdLoadingIndicator` | `md-loading-indicator` | `@aphrody/material-web/loadingindicator/loading-indicator.js` | — | +| `MdMenu` | `md-menu` | `@aphrody/material-web/menu/menu.js` | 4 | +| `MdMenuGroup` | `md-menu-group` | `@aphrody/material-web/labs/gb/components/menu/md-menu-group.js` | — | +| `MdMenuItem` | `md-menu-item` | `@aphrody/material-web/menu/menu-item.js` | 1 | +| `MdMobileStepper` | `md-mobile-stepper` | `@aphrody/material-web/mobilestepper/mobile-stepper.js` | — | +| `MdNavigationBar` | `md-navigation-bar` | `@aphrody/material-web/labs/navigationbar/navigation-bar.js` | 1 | +| `MdNavigationDrawer` | `md-navigation-drawer` | `@aphrody/material-web/labs/navigationdrawer/navigation-drawer.js` | 1 | +| `MdNavigationDrawerModal` | `md-navigation-drawer-modal` | `@aphrody/material-web/labs/navigationdrawer/navigation-drawer-modal.js` | 1 | +| `MdNavigationRail` | `md-navigation-rail` | `@aphrody/material-web/navigationrail/navigation-rail.js` | 2 | +| `MdNavigationRailItem` | `md-navigation-rail-item` | `@aphrody/material-web/navigationrail/navigation-rail-item.js` | — | +| `MdNavigationTab` | `md-navigation-tab` | `@aphrody/material-web/labs/navigationtab/navigation-tab.js` | — | +| `MdOutlinedButton` | `md-outlined-button` | `@aphrody/material-web/button/outlined-button.js` | — | +| `MdOutlinedCard` | `md-outlined-card` | `@aphrody/material-web/labs/card/outlined-card.js` | — | +| `MdOutlinedField` | `md-outlined-field` | `@aphrody/material-web/field/outlined-field.js` | — | +| `MdOutlinedIconButton` | `md-outlined-icon-button` | `@aphrody/material-web/iconbutton/outlined-icon-button.js` | — | +| `MdOutlinedSegmentedButton` | `md-outlined-segmented-button` | `@aphrody/material-web/labs/segmentedbutton/outlined-segmented-button.js` | — | +| `MdOutlinedSegmentedButtonSet` | `md-outlined-segmented-button-set` | `@aphrody/material-web/labs/segmentedbuttonset/outlined-segmented-button-set.js` | — | +| `MdOutlinedSelect` | `md-outlined-select` | `@aphrody/material-web/select/outlined-select.js` | 6 | +| `MdOutlinedTextField` | `md-outlined-text-field` | `@aphrody/material-web/textfield/outlined-text-field.js` | 2 | +| `MdPaginator` | `md-paginator` | `@aphrody/material-web/paginator/paginator.js` | 1 | +| `MdPane` | `md-pane` | `@aphrody/material-web/layout/md-pane.js` | — | +| `MdPieChart` | `md-pie-chart` | `@aphrody/material-web/charts/pie-chart.js` | — | +| `MdPopover` | `md-popover` | `@aphrody/material-web/popover/popover.js` | 2 | +| `MdPrimaryTab` | `md-primary-tab` | `@aphrody/material-web/tabs/primary-tab.js` | — | +| `MdRadarChart` | `md-radar-chart` | `@aphrody/material-web/charts/radar-chart.js` | — | +| `MdRadio` | `md-radio` | `@aphrody/material-web/radio/radio.js` | 2 | +| `MdRating` | `md-rating` | `@aphrody/material-web/rating/rating.js` | 2 | +| `MdRipple` | `md-ripple` | `@aphrody/material-web/ripple/ripple.js` | — | +| `MdScaffold` | `md-scaffold` | `@aphrody/material-web/layout/md-scaffold.js` | — | +| `MdScatterChart` | `md-scatter-chart` | `@aphrody/material-web/charts/scatter-chart.js` | — | +| `MdScheduler` | `md-scheduler` | `@aphrody/material-web/scheduler/scheduler.js` | 3 | +| `MdSearchBar` | `md-search-bar` | `@aphrody/material-web/search/search-bar.js` | 4 | +| `MdSecondaryTab` | `md-secondary-tab` | `@aphrody/material-web/tabs/secondary-tab.js` | — | +| `MdSelectOption` | `md-select-option` | `@aphrody/material-web/select/select-option.js` | — | +| `MdSideSheet` | `md-side-sheet` | `@aphrody/material-web/sheet/side-sheet.js` | 4 | +| `MdSkeleton` | `md-skeleton` | `@aphrody/material-web/skeleton/skeleton.js` | — | +| `MdSlider` | `md-slider` | `@aphrody/material-web/slider/slider.js` | 2 | +| `MdSnackbar` | `md-snackbar` | `@aphrody/material-web/snackbar/snackbar.js` | 4 | +| `MdSparkline` | `md-sparkline` | `@aphrody/material-web/charts/sparkline.js` | — | +| `MdSplitButton` | `md-split-button` | `@aphrody/material-web/labs/gb/components/splitbutton/md-split-button.js` | — | +| `MdStep` | `md-step` | `@aphrody/material-web/stepper/step.js` | — | +| `MdStepper` | `md-stepper` | `@aphrody/material-web/stepper/stepper.js` | 1 | +| `MdSubMenu` | `md-sub-menu` | `@aphrody/material-web/menu/sub-menu.js` | 4 | +| `MdSuggestionChip` | `md-suggestion-chip` | `@aphrody/material-web/chips/suggestion-chip.js` | — | +| `MdSupportingPane` | `md-supporting-pane` | `@aphrody/material-web/layout/md-supporting-pane.js` | — | +| `MdSurface` | `md-surface` | `@aphrody/material-web/surface/surface.js` | — | +| `MdSwitch` | `md-switch` | `@aphrody/material-web/switch/switch.js` | 2 | +| `MdTable` | `md-table` | `@aphrody/material-web/table/table.js` | 2 | +| `MdTabs` | `md-tabs` | `@aphrody/material-web/tabs/tabs.js` | 1 | +| `MdTextButton` | `md-text-button` | `@aphrody/material-web/button/text-button.js` | — | +| `MdTimePicker` | `md-time-picker` | `@aphrody/material-web/timepicker/time-picker.js` | 1 | +| `MdToolbar` | `md-toolbar` | `@aphrody/material-web/toolbar/md-toolbar.js` | — | +| `MdTooltip` | `md-tooltip` | `@aphrody/material-web/tooltip/tooltip.js` | 2 | +| `MdTopAppBar` | `md-top-app-bar` | `@aphrody/material-web/appbar/top-app-bar.js` | — | +| `MdTree` | `md-tree` | `@aphrody/material-web/tree/tree.js` | 1 | +| `MdTreeItem` | `md-tree-item` | `@aphrody/material-web/tree/tree-item.js` | 2 | +| `MdType` | `md-type` | `@aphrody/material-web/typography/md-type.js` | — | +| `MdVirtualScroller` | `md-virtual-scroller` | `@aphrody/material-web/virtualscroll/virtual-scroller.js` | 1 | +| `MdWebgpuCanvas` | `md-webgpu-canvas` | `@aphrody/material-web/effects/webgpu-canvas.js` | — | diff --git a/adapters/tanstack.ts b/adapters/tanstack.ts new file mode 100644 index 0000000..bcc77a6 --- /dev/null +++ b/adapters/tanstack.ts @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: Apache-2.0 +/** + * @fileoverview TanStack Table adapter for @aphrody/m3-react & @aphrody/material-web. + * Connects TanStack headless table state (sorting, pagination, column models) + * with the Material Design 3 custom element. + */ + +import type { TableColumn, TableSortCriterion } from '@aphrody/material-web/table/internal/table.js'; + +/** + * Minimal interface representing a TanStack Column Definition. + */ +export interface TanStackColumnLike<_TData = unknown> { + id?: string; + accessorKey?: string; + header?: string | ((...args: unknown[]) => unknown); + enableSorting?: boolean; + meta?: { + numeric?: boolean; + filter?: 'text' | 'number' | ''; + resizable?: boolean; + width?: string; + }; +} + +/** + * Maps TanStack Column Definitions to Aphrody Material 3 TableColumn descriptors. + */ +export function mapTanStackColumnsToM3( + columns: TanStackColumnLike[] +): TableColumn[] { + return columns.map((col) => { + const key = col.id || col.accessorKey || ''; + const label = typeof col.header === 'string' ? col.header : key; + return { + key, + label, + sortable: col.enableSorting !== false, + numeric: col.meta?.numeric ?? false, + filter: col.meta?.filter ?? '', + resizable: col.meta?.resizable ?? false, + width: col.meta?.width, + }; + }); +} + +/** + * Maps M3 Table Sort Criteria to TanStack SortingState format. + */ +export function mapM3SortToTanStack( + sortCriteria: TableSortCriterion[] +): Array<{ id: string; desc: boolean }> { + return sortCriteria.map((item) => ({ + id: item.key, + desc: item.direction === 'desc', + })); +} + +/** + * Maps TanStack SortingState to M3 TableSortCriterion format. + */ +export function mapTanStackSortToM3( + sortingState: Array<{ id: string; desc: boolean }> +): TableSortCriterion[] { + return sortingState.map((item) => ({ + key: item.id, + direction: item.desc ? 'desc' : 'asc', + })); +} diff --git a/adaptive/index.ts b/adaptive/index.ts new file mode 100644 index 0000000..ee24716 --- /dev/null +++ b/adaptive/index.ts @@ -0,0 +1,3 @@ +// Adaptive layout primitives for React (Material Design 3 window size classes). +// Import: `import { useWindowSizeClass } from "@aphrody/m3-react/adaptive"`. +export * from "./use-window-size-class.js"; diff --git a/adaptive/use-window-size-class.ts b/adaptive/use-window-size-class.ts new file mode 100644 index 0000000..d8e23e8 --- /dev/null +++ b/adaptive/use-window-size-class.ts @@ -0,0 +1,85 @@ +/** + * Adaptive layout hooks for React — Material Design 3 window size classes. + * + * The **breakpoint values and classification logic are platform-agnostic** and + * live in `@aphrody/m3-tokens/breakpoints` (pure TypeScript, no DOM). This + * module re-exports them and adds the *web* reactivity layer (`matchMedia` / + * `resize`). That split is what makes the design system portable: a React + * Native shell can share the very same `BREAKPOINTS` / `classifyWidth` from + * `m3-tokens` and feed them `useWindowDimensions().width`, while a Tauri / web + * app uses the hooks below unchanged. + * + * SSR-safe: during server render (and the first client render, to avoid a + * hydration mismatch) the hooks return `ssrSizeClass` (default `"compact"`, + * mobile-first), then settle to the real value after mount. + */ +import { useEffect, useState } from "react"; +import { + BREAKPOINTS, + classifyWidth, + mediaQuery, + type WindowSizeClass, + WINDOW_SIZE_CLASSES, +} from "@aphrody/m3-tokens/breakpoints"; + +// Re-export the canonical, platform-agnostic primitives so consumers can import +// everything adaptive from a single entrypoint (`@aphrody/m3-react/adaptive`). +export { BREAKPOINTS, classifyWidth, WINDOW_SIZE_CLASSES }; +export type { WindowSizeClass }; + +/** + * `(min-width: …)` media query string for the lower bound of a size class. + * Alias of `m3-tokens`' `mediaQuery`, kept for the web-oriented hook API. + */ +export function minWidthQuery(sizeClass: WindowSizeClass): string { + return mediaQuery(sizeClass); +} + +const isClient = typeof window !== "undefined" && typeof window.matchMedia === "function"; + +/** + * Reactive boolean for a CSS media query. SSR-safe (returns `false` until + * mounted on the client). + */ +export function useMediaQuery(query: string): boolean { + const [matches, setMatches] = useState(false); + useEffect(() => { + if (!isClient) return; + const mql = window.matchMedia(query); + const onChange = () => setMatches(mql.matches); + onChange(); + mql.addEventListener("change", onChange); + return () => mql.removeEventListener("change", onChange); + }, [query]); + return matches; +} + +/** + * Reactive M3 window size class for the viewport. Updates on resize/orientation + * change via `matchMedia`. SSR-safe — returns `ssrSizeClass` until mounted. + * + * ```tsx + * const sizeClass = useWindowSizeClass(); + * return sizeClass === "compact" ? : ; + * ``` + */ +export function useWindowSizeClass(ssrSizeClass: WindowSizeClass = "compact"): WindowSizeClass { + const [sizeClass, setSizeClass] = useState(ssrSizeClass); + useEffect(() => { + if (!isClient) return; + const update = () => setSizeClass(classifyWidth(window.innerWidth)); + update(); + window.addEventListener("resize", update, { passive: true }); + window.addEventListener("orientationchange", update); + return () => { + window.removeEventListener("resize", update); + window.removeEventListener("orientationchange", update); + }; + }, []); + return sizeClass; +} + +/** True when the viewport is at least the given size class (e.g. tablet+). */ +export function useAtLeast(sizeClass: WindowSizeClass): boolean { + return useMediaQuery(minWidthQuery(sizeClass)); +} diff --git a/build.config.ts b/build.config.ts new file mode 100644 index 0000000..7b43e41 --- /dev/null +++ b/build.config.ts @@ -0,0 +1,13 @@ +import { m3Package } from "@aphrody/m3-config/bun-build"; + +export default m3Package({ + entry: [ + "index.ts", + "transitions/index.ts", + "adaptive/index.ts", + "state/index.ts", + "adapters/tanstack.ts", + "data/index.ts", + "wrappers/*.ts", + ], +}); diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 0000000..d019fe4 --- /dev/null +++ b/bunfig.toml @@ -0,0 +1,2 @@ +[test] +preload = ["@aphrody/m3-config/test/dom"] diff --git a/data/DataTable.tsx b/data/DataTable.tsx new file mode 100644 index 0000000..00bd140 --- /dev/null +++ b/data/DataTable.tsx @@ -0,0 +1,304 @@ +// SPDX-License-Identifier: Apache-2.0 +/** + * M3 DataTable: TanStack Table v9 (sorting, global filter, pagination) rendered as a semantic `` + * styled with the `--md-sys-*` tokens. With `virtual`, rows are virtualized by TanStack Virtual instead of paged. + */ +import { useVirtualizer } from "@tanstack/react-virtual"; +import { + type ColumnDef, + columnFilteringFeature, + createColumnHelper, + createFilteredRowModel, + createPaginatedRowModel, + createSortedRowModel, + filterFn_includesString, + globalFilteringFeature, + rowPaginationFeature, + rowSortingFeature, + sortFn_alphanumeric, + sortFn_basic, + sortFn_text, + tableFeatures, + useTable, + type RowData, +} from "@tanstack/react-table"; +import { type CSSProperties, type ReactNode, useId, useRef } from "react"; + +/** The features of every DataTable: sorting, column and global filtering, pagination. */ +export const dataTableFeatures = tableFeatures({ + rowSortingFeature, + sortedRowModel: createSortedRowModel(), + sortFns: { alphanumeric: sortFn_alphanumeric, text: sortFn_text, basic: sortFn_basic }, + columnFilteringFeature, + globalFilteringFeature, + filteredRowModel: createFilteredRowModel(), + filterFns: { includesString: filterFn_includesString }, + rowPaginationFeature, + paginatedRowModel: createPaginatedRowModel(), +}); +export type DataTableFeatures = typeof dataTableFeatures; +export type DataColumn = ColumnDef; + +/** Column helper typed for DataTable: `const col = dataColumns(); col.accessor("name", { header: "Name" })`. */ +export const dataColumns = () => + createColumnHelper(); + +export interface DataTableProps { + data: TData[]; + columns: DataColumn[]; + /** Accessible caption (also the table label). */ + caption?: ReactNode; + /** Show a search field filtering every column. */ + searchable?: boolean; + searchLabel?: string; + /** Rows per page (default 25). Ignored with `virtual`. */ + pageSize?: number; + /** Virtualize rows in a scroll container of this height (px) instead of paging. */ + virtual?: { height: number; rowHeight?: number; overscan?: number }; + /** Initial sort, e.g. `[{ id: "name", desc: false }]`. */ + initialSorting?: { id: string; desc: boolean }[]; + getRowId?: (row: TData, index: number) => string; + onRowClick?: (row: TData) => void; + emptyLabel?: ReactNode; + className?: string; +} + +const EMPTY: never[] = []; + +const s = { + root: { + display: "flex", + flexDirection: "column", + gap: 8, + color: "var(--md-sys-color-on-surface)", + font: "var(--md-sys-typescale-body-medium-weight, 400) var(--md-sys-typescale-body-medium-size, 14px) / var(--md-sys-typescale-body-medium-line-height, 20px) var(--md-sys-typescale-body-medium-font, inherit)", + }, + container: { + overflow: "auto", + borderRadius: "var(--md-sys-shape-corner-medium, 12px)", + border: "1px solid var(--md-sys-color-outline-variant)", + background: "var(--md-sys-color-surface)", + }, + table: { width: "100%", borderCollapse: "collapse" }, + caption: { + textAlign: "start", + padding: "12px 16px", + font: "var(--md-sys-typescale-title-medium-weight, 500) var(--md-sys-typescale-title-medium-size, 16px) / var(--md-sys-typescale-title-medium-line-height, 24px) var(--md-sys-typescale-title-medium-font, inherit)", + }, + th: { + position: "sticky", + top: 0, + textAlign: "start", + padding: "0 16px", + height: 56, + background: "var(--md-sys-color-surface-container)", + color: "var(--md-sys-color-on-surface-variant)", + borderBottom: "1px solid var(--md-sys-color-outline-variant)", + font: "var(--md-sys-typescale-title-small-weight, 500) var(--md-sys-typescale-title-small-size, 14px) / var(--md-sys-typescale-title-small-line-height, 20px) var(--md-sys-typescale-title-small-font, inherit)", + }, + sort: { + all: "unset", + cursor: "pointer", + display: "inline-flex", + alignItems: "center", + gap: 4, + }, + td: { + padding: "0 16px", + height: 52, + borderBottom: "1px solid var(--md-sys-color-outline-variant)", + }, + search: { + alignSelf: "flex-start", + minWidth: 240, + height: 40, + padding: "0 16px", + borderRadius: "var(--md-sys-shape-corner-extra-large, 28px)", + border: "1px solid var(--md-sys-color-outline)", + background: "var(--md-sys-color-surface-container-high)", + color: "inherit", + font: "inherit", + }, + pager: { display: "flex", alignItems: "center", justifyContent: "flex-end", gap: 8 }, + button: { + height: 40, + padding: "0 16px", + borderRadius: "var(--md-sys-shape-corner-full, 20px)", + border: "1px solid var(--md-sys-color-outline)", + background: "transparent", + color: "var(--md-sys-color-primary)", + font: "inherit", + cursor: "pointer", + }, +} satisfies Record; + +const SORT_MARK = { asc: " ▲", desc: " ▼" } as const; + +export function DataTable(props: DataTableProps) { + const { + data, + columns, + caption, + searchable = false, + searchLabel = "Search", + pageSize = 25, + virtual, + initialSorting = EMPTY, + getRowId, + onRowClick, + emptyLabel = "No data", + className, + } = props; + const searchId = useId(); + const table = useTable({ + features: dataTableFeatures, + data, + columns, + getRowId, + globalFilterFn: "includesString", + initialState: { + sorting: initialSorting, + pagination: { pageIndex: 0, pageSize: virtual ? Number.MAX_SAFE_INTEGER : pageSize }, + }, + }); + const rows = table.getRowModel().rows; + const columnCount = table.getAllLeafColumns().length; + const scrollRef = useRef(null); + const rowHeight = virtual?.rowHeight ?? 52; + // oxlint-disable-next-line react/incompatible-library -- TanStack Virtual renvoie des fonctions non mémoïsables (getVirtualItems, measureElement) : le compilateur React doit l'ignorer + const virtualizer = useVirtualizer({ + count: virtual ? rows.length : 0, + getScrollElement: () => scrollRef.current, + estimateSize: () => rowHeight, + getItemKey: (index) => rows[index]?.id ?? index, + overscan: virtual?.overscan ?? 8, + initialRect: virtual ? { width: 0, height: virtual.height } : undefined, + }); + + const items = virtual ? virtualizer.getVirtualItems() : undefined; + const visible = items ? items.map((item) => rows[item.index]!) : rows; + const padTop = items?.[0]?.start ?? 0; + const padBottom = items?.length ? virtualizer.getTotalSize() - items[items.length - 1]!.end : 0; + + return ( +
+ {searchable && ( + table.setGlobalFilter(event.currentTarget.value)} + /> + )} +
+
+ {caption && } + + {table.getHeaderGroups().map((group) => ( + + {group.headers.map((header) => { + const sorted = header.column.getIsSorted(); + const label = header.isPlaceholder ? null : ; + return ( + + ); + })} + + ))} + + + {padTop > 0 && ( + + {/* oxlint-disable-next-line jsx-a11y/control-has-associated-label -- cellule de remplissage de virtualisation (ligne aria-hidden, sans contenu ni contrôle) */} + + )} + {visible.map((row) => ( + onRowClick(row.original as TData) : undefined} + style={onRowClick ? { cursor: "pointer" } : undefined} + > + {row.getAllCells().map((cell) => ( + // oxlint-disable-next-line jsx-a11y/control-has-associated-label -- cellule de données (td), pas un contrôle : son contenu est rendu par FlexRender + + ))} + + ))} + {padBottom > 0 && ( + + {/* oxlint-disable-next-line jsx-a11y/control-has-associated-label -- cellule de remplissage de virtualisation (ligne aria-hidden, sans contenu ni contrôle) */} + + )} + {rows.length === 0 && ( + + + + )} + +
{caption}
+ {header.column.getCanSort() ? ( + + ) : ( + label + )} +
+ +
+ {emptyLabel} +
+ + {!virtual && table.getPageCount() > 1 && ( + + )} + + ); +} diff --git a/data/VirtualList.tsx b/data/VirtualList.tsx new file mode 100644 index 0000000..9d5b8b9 --- /dev/null +++ b/data/VirtualList.tsx @@ -0,0 +1,109 @@ +// SPDX-License-Identifier: Apache-2.0 +/** M3 VirtualList: a `role="list"` scroll container that renders only the visible items (TanStack Virtual). */ +import { useVirtualizer } from "@tanstack/react-virtual"; +import { type CSSProperties, type Key, type ReactNode, useRef } from "react"; + +export interface VirtualListProps { + items: readonly T[]; + renderItem: (item: T, index: number) => ReactNode; + /** Scroll container height in px (the visible window). */ + height: number; + /** Estimated item size in px (default 56, the M3 one-line list item); items are measured once rendered. */ + estimateSize?: number; + overscan?: number; + getKey?: (item: T, index: number) => Key; + /** Gap between items in px. */ + gap?: number; + /** Called when the last item becomes visible (infinite loading). */ + onEndReached?: () => void; + "aria-label"?: string; + className?: string; + style?: CSSProperties; +} + +export function VirtualList(props: VirtualListProps) { + const { + items, + renderItem, + height, + estimateSize = 56, + overscan = 6, + getKey, + gap = 0, + onEndReached, + className, + style, + } = props; + const scrollRef = useRef(null); + const endReported = useRef(-1); + // oxlint-disable-next-line react/incompatible-library -- TanStack Virtual renvoie des fonctions non mémoïsables (getVirtualItems, measureElement) : le compilateur React doit l'ignorer + const virtualizer = useVirtualizer({ + count: items.length, + getScrollElement: () => scrollRef.current, + estimateSize: () => estimateSize, + overscan, + gap, + getItemKey: getKey ? (index) => getKey(items[index]!, index) : undefined, + initialRect: { width: 0, height }, + }); + const virtualItems = virtualizer.getVirtualItems(); + const last = virtualItems.at(-1); + if ( + onEndReached && + last && + last.index === items.length - 1 && + endReported.current !== items.length + ) { + endReported.current = items.length; + queueMicrotask(onEndReached); + } + + return ( +
+
    + {virtualItems.map((item) => ( +
  • + {renderItem(items[item.index]!, item.index)} +
  • + ))} +
+
+ ); +} diff --git a/data/index.ts b/data/index.ts new file mode 100644 index 0000000..b6ee83b --- /dev/null +++ b/data/index.ts @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: Apache-2.0 +/** `@aphrody/m3-react/data`: M3 data components on TanStack Table v9 and TanStack Virtual. */ +export { DataTable, dataColumns, dataTableFeatures } from "./DataTable.tsx"; +export type { DataColumn, DataTableFeatures, DataTableProps } from "./DataTable.tsx"; +export { VirtualList } from "./VirtualList.tsx"; +export type { VirtualListProps } from "./VirtualList.tsx"; diff --git a/generate.ts b/generate.ts new file mode 100644 index 0000000..5378853 --- /dev/null +++ b/generate.ts @@ -0,0 +1,416 @@ +/** + * Generator for @aphrody/m3-react. + * + * Reads migration/scripts/md-elements.txt (119 tags), resolves each tag to its + * real entry file + exported element class inside material-web/ by scanning the + * `@customElement('md-x') export class Foo` pattern, and emits one wrapper file + * per category directory using @lit/react `createComponent`. + * + * Registry archives contain compiled files only; pass the standalone fork sources. + * Run: bun run generate.ts --source /path/to/material-web [--output /tmp/preview] + */ +import { mkdirSync, readdirSync, statSync } from "node:fs"; +import { join, relative, resolve } from "node:path"; + +const HERE = import.meta.dir; +const args = process.argv.slice(2); +let sourceRoot: string | undefined; +let outputRoot = HERE; +for (let i = 0; i < args.length; i++) { + const flag = args[i]; + const value = args[++i]; + if ((flag !== "--source" && flag !== "--output") || !value || value.startsWith("--")) { + throw new Error( + "Usage: bun run generate.ts --source /path/to/material-web [--output /tmp/preview]", + ); + } + if (flag === "--source") sourceRoot = resolve(value); + else outputRoot = resolve(value); +} +if (!sourceRoot) { + throw new Error( + "Material Web npm archives contain compiled files only. Pass --source /path/to/material-web (the standalone fork checkout).", + ); +} +const MW = sourceRoot; +const BUNDLES = ["all.ts", "aphrody-components.ts", "aphrody-labs.ts"]; +const bundlePresent = await Promise.all( + BUNDLES.map((bundle) => Bun.file(join(MW, bundle)).exists()), +); +for (const [index, bundle] of BUNDLES.entries()) { + if (!bundlePresent[index]) { + throw new Error(`Material Web source checkout is missing ${bundle}: ${MW}`); + } +} +const ELEMENTS_TXT = join(HERE, "md-elements.txt"); +const OUT_DIR = join(outputRoot, "wrappers"); + +// ── 1. Tag → PascalCase wrapper name (CONVENTIONS §2) ────────────────────── +const pascal = (tag: string): string => + tag + .split("-") + .map((s) => s.charAt(0).toUpperCase() + s.slice(1)) + .join(""); + +// ── 2. Walk material-web for every `@customElement('md-…')` entry file ────── +// Skip tests, internal/, demos, catalog. Collect tag → [{file, className}]. +const SKIP_DIRS = new Set([ + "node_modules", + "internal", + "demo", + "demos", + "catalog", + "dist-aphrody", + "sass", + "testing", +]); +const RE = /@customElement\(\s*['"]([a-z0-9-]+)['"]\s*\)\s*\nexport class (\w+)/g; + +interface Def { + file: string; + className: string; +} + +type EventMap = Record; + +interface Resolved { + tag: string; + name: string; + file: string; + className: string; + events: EventMap; +} + +interface Unresolved { + tag: string; + reason: string; +} + +const defs: Map = new Map(); + +async function walk(dir: string): Promise { + for (const name of readdirSync(dir)) { + const full = join(dir, name); + const st = statSync(full); + if (st.isDirectory()) { + if (SKIP_DIRS.has(name)) continue; + // oxlint-disable-next-line no-await-in-loop -- depth-first walk in readdir order: the defs lists it appends to feed the generated output order + await walk(full); + } else if (name.endsWith(".ts") && !name.endsWith("_test.ts") && !name.endsWith(".d.ts")) { + // oxlint-disable-next-line no-await-in-loop -- files are registered in walk order (defs lists feed the generated output), so reads stay sequential + const src = await Bun.file(full).text(); + let m; + RE.lastIndex = 0; + while ((m = RE.exec(src))) { + const [, tag, className] = m; + if (!tag.startsWith("md-")) continue; + const rel = relative(MW, full).replace(/\.ts$/, ".js"); // import as .js (side-effect register) + if (!defs.has(tag)) defs.set(tag, []); + defs.get(tag)!.push({ file: rel, className }); + } + } + } +} +await walk(MW); + +// ── 3. Canonical import path preference ──────────────────────────────────── +// Tags re-exported by the three bundles must import from the bundled entry +// path. Build the set of canonical relative paths from those bundles. +const bundleImports = new Set(); +const bundleSources = await Promise.all(BUNDLES.map((bundle) => Bun.file(join(MW, bundle)).text())); +for (const src of bundleSources) { + for (const m of src.matchAll(/from ['"]\.\/([^'"]+\.js)['"]/g)) bundleImports.add(m[1]); +} + +function pickDef(tag: string, candidates: Def[]): Def { + // 1. Prefer a candidate whose path is referenced by a bundle entry. + const bundled = candidates.find((c) => bundleImports.has(c.file)); + if (bundled) return bundled; + // 2. Otherwise prefer a non-labs/gb path (canonical source dir). + const nonGb = candidates.find((c) => !c.file.startsWith("labs/gb/")); + if (nonGb) return nonGb; + // 3. Fall back to the first (labs/gb only — e.g. md-button, md-split-button). + return candidates[0]; +} + +// ── 4. Event maps per category (verified against new Event/CustomEvent in MW) ─ +// Generic UI events React already types; the md elements re-dispatch the native +// `input`/`change`. Component-specific events use their real names. +// Every name below is the REAL event string verified by grepping +// `@fires` / `new (Custom)?Event('…')` in material-web//internal/*.ts. +// Upstream components use bare names (input/change/opening/…); aphrody fork +// components namespace theirs (paginator:page, table:sort, side-sheet:opening…). +const EVENTS_BY_TAG: Record = { + // ── fork: parity additions (event names verified against source dispatch) ── + "md-rating": { onChange: "change", onInput: "input" }, + "md-carousel": { onChange: "carousel-change" }, + "md-scheduler": { + onNavigate: "scheduler:navigate", + onSelect: "scheduler:select", + onEventClick: "scheduler:event-click", + }, + "md-date-range-picker": { + onInput: "input", + onChange: "change", + onDateRangeChange: "date-range-picker:change", + }, + "md-date-time-picker": { + onInput: "input", + onChange: "change", + onDateTimeChange: "date-time-picker:change", + }, + "md-popover": { onOpened: "opened", onClosed: "closed" }, + "md-alert": { onClose: "close" }, + "md-avatar": { onError: "error" }, + // ── Form inputs (upstream: re-dispatched native input/change) ────────── + "md-checkbox": { onChange: "change", onInput: "input" }, + "md-radio": { onChange: "change", onInput: "input" }, + "md-switch": { onChange: "change", onInput: "input" }, + "md-slider": { onChange: "change", onInput: "input" }, + "md-filled-text-field": { onChange: "change", onInput: "input" }, + "md-outlined-text-field": { onChange: "change", onInput: "input" }, + "md-filled-select": { + onChange: "change", + onInput: "input", + onOpening: "opening", + onOpened: "opened", + onClosing: "closing", + onClosed: "closed", + }, + "md-outlined-select": { + onChange: "change", + onInput: "input", + onOpening: "opening", + onOpened: "opened", + onClosing: "closing", + onClosed: "closed", + }, + // fork: search-bar fires input + search + search:open/close + "md-search-bar": { + onInput: "input", + onSearch: "search", + onSearchOpen: "search:open", + onSearchClose: "search:close", + }, + // fork: autocomplete fires input + autocomplete:select + "md-autocomplete": { onInput: "input", onSelect: "autocomplete:select" }, + // ── Overlays (upstream) ──────────────────────────────────────────────── + "md-dialog": { + onOpen: "open", + onOpened: "opened", + onClose: "close", + onClosed: "closed", + onCancel: "cancel", + }, + "md-menu": { + onOpening: "opening", + onOpened: "opened", + onClosing: "closing", + onClosed: "closed", + }, + "md-menu-item": { onCloseMenu: "close-menu" }, + "md-sub-menu": { + onOpening: "opening", + onOpened: "opened", + onClosing: "closing", + onClosed: "closed", + }, + // fork: snackbar (bare opening/opened/closing/closed) + "md-snackbar": { + onOpening: "opening", + onOpened: "opened", + onClosing: "closing", + onClosed: "closed", + }, + // fork: sheets namespace their lifecycle events + "md-bottom-sheet": { + onOpening: "bottom-sheet:opening", + onOpened: "bottom-sheet:opened", + onClosing: "bottom-sheet:closing", + onClosed: "bottom-sheet:closed", + }, + "md-side-sheet": { + onOpening: "side-sheet:opening", + onOpened: "side-sheet:opened", + onClosing: "side-sheet:closing", + onClosed: "side-sheet:closed", + }, + // labs navigation drawer + "md-navigation-drawer": { + onNavigationDrawerChanged: "navigation-drawer-changed", + }, + "md-navigation-drawer-modal": { + onNavigationDrawerChanged: "navigation-drawer-changed", + }, + // ── Selection / navigation ───────────────────────────────────────────── + "md-tabs": { onChange: "change" }, // upstream: bubbling 'change' + "md-navigation-bar": { onActivated: "navigation-bar-activated" }, // labs + "md-navigation-rail": { + onChange: "navigation-rail:change", + onItemActivate: "navigation-rail-item:activate", + }, // fork + "md-input-chip": { onRemove: "remove", onUpdateFocus: "update-focus" }, + "md-button-group": { onChange: "button-group:change" }, // fork + "md-fab-menu": { onOpen: "fab-menu:open", onClose: "fab-menu:close" }, // fork + "md-fab-menu-item": { onClick: "fab-menu-item:click" }, // fork + // ── Disclosure (fork) ────────────────────────────────────────────────── + "md-expansion-panel": { onToggle: "expansion:toggle" }, + "md-stepper": { onChange: "stepper:change" }, + // ── Pickers (fork) ───────────────────────────────────────────────────── + "md-date-picker": { onChange: "date-picker:change" }, + "md-time-picker": { onChange: "time-picker:change" }, + // ── Data (fork) ──────────────────────────────────────────────────────── + "md-table": { + onSort: "table:sort", + onSelectionChange: "table:selection-change", + }, + "md-paginator": { onPage: "paginator:page" }, + "md-tree": { onSelect: "tree:select" }, + "md-tree-item": { + onToggle: "tree-item:toggle", + onActivate: "tree-item:activate", + }, + "md-virtual-scroller": { onRange: "virtual-scroll:range" }, + // ── Misc (fork) ──────────────────────────────────────────────────────── + "md-tooltip": { onShow: "tooltip-show", onHide: "tooltip-hide" }, +}; + +// ── 5. Resolve every requested tag ───────────────────────────────────────── +const tags = (await Bun.file(ELEMENTS_TXT).text()) + .split("\n") + .map((s) => s.trim()) + .filter(Boolean); + +const resolved: Resolved[] = []; +const unresolved: Unresolved[] = []; + +for (const tag of tags) { + const candidates = defs.get(tag); + if (!candidates || candidates.length === 0) { + unresolved.push({ + tag, + reason: "no @customElement entry file found in material-web/", + }); + continue; + } + const def = pickDef(tag, candidates); + resolved.push({ + tag, + name: pascal(tag), + file: def.file, + className: def.className, + events: EVENTS_BY_TAG[tag] ?? {}, + }); +} + +// ── 6. Group by top-level category dir, emit one wrapper file per group ───── +const groupOf = (file: string): string => file.split("/")[0].replace(/\..*$/, ""); +const groups: Map = new Map(); +for (const r of resolved) { + const g = groupOf(r.file); + if (!groups.has(g)) groups.set(g, []); + groups.get(g)!.push(r); +} + +mkdirSync(OUT_DIR, { recursive: true }); + +const HEADER = `// AUTO-GENERATED by generate.ts — do not edit by hand. Run \`bun run generate\`.\n`; +const fmtEvents = (events: EventMap): string => { + const keys = Object.keys(events); + if (keys.length === 0) return "{}"; + const body = keys.map((k) => `${k}: '${events[k]}'`).join(", "); + return `{${body}}`; +}; + +const indexLines = [ + HEADER, + `import * as React from 'react';`, + `import {createComponent} from '@lit/react';`, + "", +]; +// Group elements that share the same import file (e.g. several tags per module is rare; one file = one element here). +const writes: Promise[] = []; +for (const [group, items] of [...groups.entries()].toSorted()) { + const fileName = `${group}.ts`; + const lines = [ + HEADER, + `import * as React from 'react';`, + `import {createComponent} from '@lit/react';`, + "", + ]; + // imports — alias element class to Element to avoid clashing with wrapper const. + for (const r of items) { + lines.push( + `import {${r.className} as ${r.name}Element} from '@aphrody/material-web/${r.file}';`, + ); + } + lines.push(""); + for (const r of items) { + lines.push(`export const ${r.name} = createComponent({`); + lines.push(` react: React,`); + lines.push(` tagName: '${r.tag}',`); + lines.push(` elementClass: ${r.name}Element,`); + lines.push(` events: ${fmtEvents(r.events)},`); + lines.push(`});`); + lines.push(""); + } + writes.push(Bun.write(join(OUT_DIR, fileName), lines.join("\n"))); + indexLines.push(`export * from './wrappers/${group}.js';`.replace("./wrappers", "./")); +} +await Promise.all(writes); + +// index.ts at package root re-exports all groups. +const idx = [HEADER]; +for (const [group] of [...groups.entries()].toSorted()) { + idx.push(`export * from './wrappers/${group}.js';`); +} +idx.push(""); +await Bun.write(join(outputRoot, "index.ts"), idx.join("\n")); + +// ── 7. Report ────────────────────────────────────────────────────────────── +console.log(`Tags requested: ${tags.length}`); +console.log(`Wrappers generated: ${resolved.length} across ${groups.size} files`); +if (unresolved.length) { + console.log(`Unresolved (${unresolved.length}):`); + for (const u of unresolved) console.log(` - ${u.tag}: ${u.reason}`); +} +// Emit a machine-readable manifest for the README. +await Bun.write( + join(outputRoot, ".manifest.json"), + JSON.stringify( + { + resolved: resolved.map(({ tag, name, file, className, events }) => ({ + tag, + name, + file, + className, + events, + })), + unresolved, + }, + null, + 2, + ), +); + +// Keep the README's component list in sync (it is the last section). This makes +// the wrapper count + table self-maintaining — never stale after a `generate`. +const README = join(outputRoot, "README.md"); +const rows = resolved + .toSorted((a, b) => a.name.localeCompare(b.name)) + .map((r) => { + const ev = Object.keys(r.events ?? {}).length || "—"; + return `| \`${r.name}\` | \`${r.tag}\` | \`@aphrody/material-web/${r.file}\` | ${ev} |`; + }) + .join("\n"); +const listSection = + `## Liste complète des wrappers (${resolved.length})\n\n` + + `> Régénérée par \`generate.ts\` (\`.manifest.json\`). ${unresolved.length} non résolu(s).\n\n` + + `| Wrapper | Tag | Import élément | # events |\n| --- | --- | --- | --- |\n${rows}\n`; +try { + const readme = await Bun.file(README).text(); + const updated = readme.replace(/## Liste complète des wrappers \(\d+\)[\s\S]*$/, listSection); + if (updated !== readme) await Bun.write(README, updated); +} catch { + /* README optional */ +} diff --git a/index.ts b/index.ts new file mode 100644 index 0000000..3c0c786 --- /dev/null +++ b/index.ts @@ -0,0 +1,66 @@ +// AUTO-GENERATED by generate.ts — do not edit by hand. Run `bun run generate`. + +export * from './wrappers/alert.js'; +export * from './wrappers/appbar.js'; +export * from './wrappers/autocomplete.js'; +export * from './wrappers/avatar.js'; +export * from './wrappers/backdrop.js'; +export * from './wrappers/badge.js'; +export * from './wrappers/banner.js'; +export * from './wrappers/breadcrumbs.js'; +export * from './wrappers/button.js'; +export * from './wrappers/buttongroup.js'; +export * from './wrappers/canvas3d.js'; +export * from './wrappers/card.js'; +export * from './wrappers/card3d.js'; +export * from './wrappers/carousel.js'; +export * from './wrappers/charts.js'; +export * from './wrappers/checkbox.js'; +export * from './wrappers/chips.js'; +export * from './wrappers/datepicker.js'; +export * from './wrappers/dialog.js'; +export * from './wrappers/divider.js'; +export * from './wrappers/effects.js'; +export * from './wrappers/elevation.js'; +export * from './wrappers/expansion.js'; +export * from './wrappers/fab.js'; +export * from './wrappers/fabmenu.js'; +export * from './wrappers/field.js'; +export * from './wrappers/focus.js'; +export * from './wrappers/globe3d.js'; +export * from './wrappers/gridlist.js'; +export * from './wrappers/icon.js'; +export * from './wrappers/iconbutton.js'; +export * from './wrappers/labs.js'; +export * from './wrappers/layout.js'; +export * from './wrappers/link.js'; +export * from './wrappers/list.js'; +export * from './wrappers/loadingindicator.js'; +export * from './wrappers/menu.js'; +export * from './wrappers/mobilestepper.js'; +export * from './wrappers/navigationrail.js'; +export * from './wrappers/paginator.js'; +export * from './wrappers/popover.js'; +export * from './wrappers/progress.js'; +export * from './wrappers/radio.js'; +export * from './wrappers/rating.js'; +export * from './wrappers/ripple.js'; +export * from './wrappers/scheduler.js'; +export * from './wrappers/search.js'; +export * from './wrappers/select.js'; +export * from './wrappers/sheet.js'; +export * from './wrappers/skeleton.js'; +export * from './wrappers/slider.js'; +export * from './wrappers/snackbar.js'; +export * from './wrappers/stepper.js'; +export * from './wrappers/surface.js'; +export * from './wrappers/switch.js'; +export * from './wrappers/table.js'; +export * from './wrappers/tabs.js'; +export * from './wrappers/textfield.js'; +export * from './wrappers/timepicker.js'; +export * from './wrappers/toolbar.js'; +export * from './wrappers/tooltip.js'; +export * from './wrappers/tree.js'; +export * from './wrappers/typography.js'; +export * from './wrappers/virtualscroll.js'; diff --git a/material-web-shims.d.ts b/material-web-shims.d.ts new file mode 100644 index 0000000..11bffd7 --- /dev/null +++ b/material-web-shims.d.ts @@ -0,0 +1,26 @@ +// Ambient shims for @aphrody/material-web build artifacts that are NOT present in this +// source checkout of the fork. +// +// material-web's build pipeline generates two kinds of style modules that the +// component sources import but that only exist after `npm run build` +// (build:sass -> build:css-to-ts): +// - `*-styles.cssresult.js` (upstream + aphrody-labs SASS components) +// - `*.css` (labs/gb Google-Sans components import raw CSS as a CSSResult) +// +// We only need the *type* of these modules so the wrappers typecheck; the +// actual CSS is irrelevant to the React wrapper layer (it ships with the +// element at runtime). Both export `styles: CSSResult`. +// NOTE: typed as `any`, not `CSSResult`. material-web bundles its own nested +// `lit@2` whose `CSSResult` is a nominally different class from the wrappers' +// `lit@3` `CSSResult`; importing either side here would make these style +// modules non-assignable to the elements' `static styles: CSSResultOrNative[]`. +// The wrapper layer never touches these values, so `any` is correct and safe. +declare module "*.cssresult.js" { + export const styles: any; +} + +declare module "*.css" { + const styles: any; + export default styles; + export { styles }; +} diff --git a/md-elements.txt b/md-elements.txt new file mode 100644 index 0000000..93ee56a --- /dev/null +++ b/md-elements.txt @@ -0,0 +1,121 @@ +md-accordion +md-assist-chip +md-autocomplete +md-badge +md-banner +md-bottom-app-bar +md-bottom-sheet +md-branded-fab +md-button +md-button-group +md-card +md-carousel +md-carousel-item +md-checkbox +md-chip-set +md-circular-progress +md-date-picker +md-dialog +md-divider +md-elevated-button +md-elevated-card +md-elevation +md-expansion-panel +md-fab +md-fab-menu +md-fab-menu-item +md-filled-button +md-filled-card +md-filled-field +md-filled-icon-button +md-filled-select +md-filled-text-field +md-filled-tonal-button +md-filled-tonal-icon-button +md-filter-chip +md-focus-ring +md-grid-list +md-grid-tile +md-icon +md-icon-button +md-input-chip +md-item +md-linear-progress +md-list +md-list-detail +md-list-item +md-loading-indicator +md-menu +md-menu-group +md-menu-item +md-navigation-bar +md-navigation-drawer +md-navigation-drawer-modal +md-navigation-rail +md-navigation-rail-item +md-navigation-tab +md-outlined-button +md-outlined-card +md-outlined-field +md-outlined-icon-button +md-outlined-segmented-button +md-outlined-segmented-button-set +md-outlined-select +md-outlined-text-field +md-paginator +md-pane +md-primary-tab +md-radio +md-ripple +md-scaffold +md-search-bar +md-secondary-tab +md-select-option +md-side-sheet +md-slider +md-snackbar +md-split-button +md-step +md-stepper +md-sub-menu +md-suggestion-chip +md-supporting-pane +md-switch +md-table +md-tabs +md-text-button +md-time-picker +md-toolbar +md-tooltip +md-top-app-bar +md-tree +md-tree-item +md-virtual-scroller +md-webgpu-canvas +md-avatar +md-avatar-group +md-alert +md-skeleton +md-breadcrumbs +md-link +md-surface +md-backdrop +md-popover +md-rating +md-line-chart +md-bar-chart +md-pie-chart +md-area-chart +md-sparkline +md-gauge +md-date-range-picker +md-mobile-stepper +md-scatter-chart +md-radar-chart +md-date-time-picker +md-scheduler +md-type +md-3d-canvas +md-3d-globe +md-3d-card + diff --git a/package.json b/package.json new file mode 100644 index 0000000..418143b --- /dev/null +++ b/package.json @@ -0,0 +1,106 @@ +{ + "name": "@aphrody/m3-react", + "version": "1.4.4", + "description": "React wrappers (@lit/react createComponent) for every element of the @aphrody/material-web fork.", + "homepage": "https://github.com/aphrody-code/material-web/tree/main/packages/react#readme", + "bugs": { + "url": "https://github.com/aphrody-code/material-web/issues" + }, + "license": "Apache-2.0", + "author": "aphrody-code", + "repository": { + "type": "git", + "url": "git+https://github.com/aphrody-code/material-web.git", + "directory": "packages/m3-react" + }, + "files": [ + "dist", + "index.ts", + "transitions", + "adaptive", + "state", + "adapters", + "wrappers", + "data" + ], + "type": "module", + "sideEffects": [ + "./dist/index.js", + "./dist/**/*.js" + ], + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "bun": "./index.ts", + "types": "./dist/index.d.ts", + "import": "./index.ts", + "default": "./dist/index.js" + }, + "./transitions": { + "bun": "./transitions/index.ts", + "types": "./dist/transitions/index.d.ts", + "import": "./transitions/index.ts", + "default": "./dist/transitions/index.js" + }, + "./adaptive": { + "bun": "./adaptive/index.ts", + "types": "./dist/adaptive/index.d.ts", + "import": "./adaptive/index.ts", + "default": "./dist/adaptive/index.js" + }, + "./state": { + "bun": "./state/index.ts", + "types": "./dist/state/index.d.ts", + "import": "./state/index.ts", + "default": "./dist/state/index.js" + }, + "./adapters/tanstack": { + "bun": "./adapters/tanstack.ts", + "types": "./dist/adapters/tanstack.d.ts", + "import": "./adapters/tanstack.ts", + "default": "./dist/adapters/tanstack.js" + }, + "./data": { + "bun": "./data/index.ts", + "types": "./dist/data/index.d.ts", + "import": "./data/index.ts", + "default": "./dist/data/index.js" + }, + "./package.json": "./package.json", + "./*": { + "bun": "./wrappers/*.ts", + "types": "./dist/wrappers/*.d.ts", + "import": "./wrappers/*.ts", + "default": "./dist/wrappers/*.js" + } + }, + "publishConfig": { + "access": "public" + }, + "scripts": { + "generate": "bun run generate.ts", + "build": "bun ../../scripts/build.ts build --only $npm_package_name", + "typecheck": "bun check --noEmit", + "typecheck:fast": "bun check --noEmit", + "test": "bun test" + }, + "dependencies": { + "@aphrody/m3-tokens": "workspace:*", + "@aphrody/material-web": "catalog:", + "@lit/react": "catalog:", + "@tanstack/react-table": "catalog:", + "@tanstack/react-virtual": "catalog:" + }, + "devDependencies": { + "@aphrody/m3-config": "workspace:*" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + }, + "engines": { + "bun": ">=1.4.4" + } +} diff --git a/state/index.ts b/state/index.ts new file mode 100644 index 0000000..e23eda1 --- /dev/null +++ b/state/index.ts @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: Apache-2.0 + +/** + * Hoisted state primitives for Material Design 3 React components. + * + * Currently exposes {@link useTextFieldState} / {@link WebTextFieldState}: the + * Web counterpart of Jetpack Compose `TextFieldState` / `rememberTextFieldState`, + * an observable, transactional text + selection container for driving + * `` / `` without spurious + * re-renders. + * + * @packageDocumentation + */ + +export { + WebTextFieldState, + useTextFieldState, + bindTextFieldState, + syncFromElement, +} from "./use-text-field-state.js"; +export type { + TextFieldSelection, + TextFieldEditBuffer, + TextFieldStateListener, + SelectionCapableElement, +} from "./use-text-field-state.js"; diff --git a/state/use-text-field-state.ts b/state/use-text-field-state.ts new file mode 100644 index 0000000..f4f5c6f --- /dev/null +++ b/state/use-text-field-state.ts @@ -0,0 +1,280 @@ +// SPDX-License-Identifier: Apache-2.0 + +/** + * Hoisted, observable text-field state for Material Design 3 React text fields. + * + * This is the Web counterpart of Jetpack Compose's `TextFieldState` / + * `rememberTextFieldState()` paradigm: a persistent state container holding the + * current text plus the cursor/selection, mutated through atomic, transactional + * edits (`state.edit(...)`). It is designed to drive `` + * / `` (wrappers `MdOutlinedTextField` / + * `MdFilledTextField`) without spurious re-renders while preserving the caret + * and selection inside the Lit Shadow DOM. + * + * SSR-safe: the module never touches `window`/`document` at import time, and the + * hook subscribes lazily through `useSyncExternalStore`. + * + * @packageDocumentation + */ + +import { useState, useSyncExternalStore } from "react"; + +/** A caret position (`start === end`) or a directed/undirected selection range. */ +export interface TextFieldSelection { + start: number; + end: number; +} + +/** The mutable buffer exposed inside an {@link WebTextFieldState.edit} block. */ +export interface TextFieldEditBuffer { + text: string; + selection: TextFieldSelection; +} + +/** A listener notified after every committed mutation. */ +export type TextFieldStateListener = () => void; + +function clampSelection(selection: TextFieldSelection, length: number): TextFieldSelection { + const clamp = (n: number): number => { + if (!Number.isFinite(n) || n < 0) { + return 0; + } + return n > length ? length : Math.floor(n); + }; + const start = clamp(selection.start); + const end = clamp(selection.end); + // Keep start <= end for an undirected range; callers that need direction can + // read it back as-is when start <= end already holds. + return start <= end ? { start, end } : { start: end, end: start }; +} + +/** + * Observable, transactional text-field state container. + * + * Mirrors Compose `TextFieldState`: the only way to mutate text + selection + * together is the atomic {@link edit} transaction, so a single notification is + * dispatched per logical change (text and caret never observed mid-update). + */ +export class WebTextFieldState { + private _text: string; + private _selection: TextFieldSelection; + private readonly _listeners = new Set(); + + constructor(initialText = "", initialSelection: TextFieldSelection = { start: 0, end: 0 }) { + this._text = initialText; + this._selection = clampSelection(initialSelection, initialText.length); + // Bind so the methods can be passed directly to useSyncExternalStore / + // addEventListener without losing `this`. + this.subscribe = this.subscribe.bind(this); + this.getSnapshot = this.getSnapshot.bind(this); + } + + /** Current text value. */ + get text(): string { + return this._text; + } + + /** Current selection/caret, always clamped to `[0, text.length]`. */ + get selection(): TextFieldSelection { + return this._selection; + } + + /** + * Stable identity snapshot for `useSyncExternalStore`. Returns the raw text; + * because edits are transactional, text identity changes iff a commit + * happened, which is the correct "did the store change" signal for the value. + */ + getSnapshot(): string { + return this._text; + } + + /** + * Move the caret / set the selection without changing text. Out-of-range + * indices are clamped. Notifies subscribers only when the range changes. + */ + setSelection(start: number, end: number = start): void { + const next = clampSelection({ start, end }, this._text.length); + if (next.start === this._selection.start && next.end === this._selection.end) { + return; + } + this._selection = next; + this.notify(); + } + + /** + * Replace the whole text value, re-clamping the existing selection against + * the new length. Prefer {@link edit} when you also need to move the caret. + */ + setText(text: string): void { + if (text === this._text) { + return; + } + this._text = text; + this._selection = clampSelection(this._selection, text.length); + this.notify(); + } + + /** + * Atomic transaction over text + selection. The block receives a mutable + * buffer initialised from the current state; mutations are validated and + * committed together, dispatching a single notification. If the block throws, + * nothing is committed (the state is left untouched). + * + * @example + * state.edit((buffer) => { + * buffer.text = buffer.text.toUpperCase(); + * buffer.selection = { start: buffer.text.length, end: buffer.text.length }; + * }); + */ + edit(block: (buffer: TextFieldEditBuffer) => void): void { + const buffer: TextFieldEditBuffer = { + text: this._text, + selection: { start: this._selection.start, end: this._selection.end }, + }; + block(buffer); + const nextText = buffer.text; + const nextSelection = clampSelection(buffer.selection, nextText.length); + const changed = + nextText !== this._text || + nextSelection.start !== this._selection.start || + nextSelection.end !== this._selection.end; + if (!changed) { + return; + } + this._text = nextText; + this._selection = nextSelection; + this.notify(); + } + + /** + * Convenience editor: replace the current selection (or insert at the caret) + * with `replacement`, then place the caret right after the inserted text. + */ + replaceSelection(replacement: string): void { + this.edit((buffer) => { + const { start, end } = buffer.selection; + buffer.text = buffer.text.slice(0, start) + replacement + buffer.text.slice(end); + const caret = start + replacement.length; + buffer.selection = { start: caret, end: caret }; + }); + } + + /** + * Subscribe to commits. Returns an unsubscribe function. Shaped for direct + * use as the first argument of `useSyncExternalStore`. + */ + subscribe(listener: TextFieldStateListener): () => void { + this._listeners.add(listener); + return () => { + this._listeners.delete(listener); + }; + } + + private notify(): void { + for (const listener of this._listeners) { + listener(); + } + } +} + +/** + * Hook recreating Compose's `rememberTextFieldState()`. + * + * The {@link WebTextFieldState} instance is created exactly once (lazy + * `useState` initialiser) and stays stable across re-renders. The component + * re-renders only when the committed text changes, via `useSyncExternalStore` — + * chosen over a manual `useEffect` + `forceUpdate` because it is the React 19 + * tearing-safe primitive: it reads the snapshot synchronously during render + * (so concurrent renders never observe a stale value) and avoids the + * double-render that a "subscribe in effect then setState" approach incurs on + * mount. The `getServerSnapshot` argument makes it SSR/hydration-safe. + * + * Because selection lives on the same instance, callers that need caret-driven + * re-renders can subscribe to it themselves; the default snapshot intentionally + * tracks text only, so moving the caret does not re-render the whole subtree. + * + * @param initialValue Initial text; caret is placed at the end of it. + * @returns The stable, observable state container. + */ +export function useTextFieldState(initialValue = ""): WebTextFieldState { + const [state] = useState( + () => + new WebTextFieldState(initialValue, { + start: initialValue.length, + end: initialValue.length, + }), + ); + // Subscribe to text commits. The snapshot is the raw string, so React bails + // out of re-rendering when an edit leaves the text unchanged. + useSyncExternalStore(state.subscribe, state.getSnapshot, state.getSnapshot); + return state; +} + +/** + * A structural view of a focusable text-entry element exposing the standard + * selection API. Both a raw ``/`