chore: migrate package sources from Aphrody

This commit is contained in:
aphrody-dev committed 2026-10-12 08:24:01 +02:00
1 parent d30b18b099
commit 52c1293981
100 files changed
+5898 -2

No files matched your search

+979
View File
@@ -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": []
}
+204
View File
@@ -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/<dir>/<el>.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 (
<main>
<Demo />
</main>
);
}
```
```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<React.ComponentRef<typeof MdDialog>>(null);
const [name, setName] = useState("");
return (
<>
<MdFilledButton onClick={() => dialogRef.current?.show()}>Ouvrir</MdFilledButton>
<MdOutlinedTextField
label="Nom"
value={name}
onInput={(e) => setName((e.target as HTMLInputElement).value)}
/>
<MdTabs>
<MdPrimaryTab>A</MdPrimaryTab>
<MdPrimaryTab>B</MdPrimaryTab>
</MdTabs>
<MdLineChart
smooth
showMarkers
categories={["Jan", "Fév", "Mar"]}
series={[{ label: "CA", data: [12, 19, 8] }]}
/>
<MdDialog ref={dialogRef}>
<div slot="headline">Titre</div>
<div slot="content">Ouvert via ref impérative.</div>
<div slot="actions">
<MdFilledButton onClick={() => dialogRef.current?.close()}>Fermer</MdFilledButton>
</div>
</MdDialog>
</>
);
}
```
### 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<React.ComponentRef<typeof MdDialog>>(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). `<MdLineChart series={[…]} categories={[…]} />` règle donc
`.series` / `.categories` directement — pas de JSON à sérialiser.
---
## 3. SSR / hydration + parade FOUC
Côté serveur, un `<md-filled-button>` 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.
+327 -2
View File
@@ -1,2 +1,327 @@
# aphrody-m3-react
Aphrody package @aphrody/m3-react
<!-- SPDX-License-Identifier: Apache-2.0 -->
# `@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 (
<form>
{/* controlled : la signature React change — e.target.value, PAS (e, value) */}
<MdOutlinedTextField
label="Email"
value={email}
onInput={(e) => setEmail((e.target as HTMLInputElement).value)}
/>
<label>
<MdCheckbox
checked={accept}
onChange={(e) => setAccept((e.target as HTMLInputElement).checked)}
/>
J'accepte
</label>
<MdFilledButton onClick={() => console.log({ email, accept })}>Valider</MdFilledButton>
</form>
);
}
```
### 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";
<MdDialog open onClosed={(e) => console.log((e.target as any).returnValue)}>
<div slot="headline">Supprimer ?</div>
<form slot="content" method="dialog">
Cette action est irréversible.
</form>
<div slot="actions">
<MdTextButton value="cancel">Annuler</MdTextButton>
<MdFilledButton value="delete">
<MdIcon slot="icon">delete</MdIcon>
Supprimer
</MdFilledButton>
</div>
</MdDialog>;
```
> **`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/<categorie>.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/<dir>/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` | — |
+69
View File
@@ -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 <md-table> 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<TData = unknown>(
columns: TanStackColumnLike<TData>[]
): 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',
}));
}
+3
View File
@@ -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";
+85
View File
@@ -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" ? <MdNavigationBar/> : <MdNavigationRail/>;
* ```
*/
export function useWindowSizeClass(ssrSizeClass: WindowSizeClass = "compact"): WindowSizeClass {
const [sizeClass, setSizeClass] = useState<WindowSizeClass>(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));
}
+13
View File
@@ -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",
],
});
+2
View File
@@ -0,0 +1,2 @@
[test]
preload = ["@aphrody/m3-config/test/dom"]
+304
View File
@@ -0,0 +1,304 @@
// SPDX-License-Identifier: Apache-2.0
/**
* M3 DataTable: TanStack Table v9 (sorting, global filter, pagination) rendered as a semantic `<table>`
* 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<TData extends RowData = any> = ColumnDef<DataTableFeatures, TData, any>;
/** Column helper typed for DataTable: `const col = dataColumns<Person>(); col.accessor("name", { header: "Name" })`. */
export const dataColumns = <TData extends RowData = any>() =>
createColumnHelper<DataTableFeatures, TData>();
export interface DataTableProps<TData extends RowData = any> {
data: TData[];
columns: DataColumn<TData>[];
/** 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<string, CSSProperties>;
const SORT_MARK = { asc: " ▲", desc: " ▼" } as const;
export function DataTable<TData extends RowData = any>(props: DataTableProps<TData>) {
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<DataTableFeatures, TData>({
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<HTMLDivElement>(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 (
<div className={className} style={s.root} data-m3-data-table="">
{searchable && (
<input
id={searchId}
type="search"
aria-label={searchLabel}
placeholder={searchLabel}
style={s.search}
value={String(table.store.state.globalFilter ?? "")}
onChange={(event) => table.setGlobalFilter(event.currentTarget.value)}
/>
)}
<div
ref={scrollRef}
style={virtual ? { ...s.container, height: virtual.height } : s.container}
data-m3-scroll=""
>
<table style={s.table} aria-rowcount={virtual ? rows.length + 1 : undefined}>
{caption && <caption style={s.caption}>{caption}</caption>}
<thead>
{table.getHeaderGroups().map((group) => (
<tr key={group.id}>
{group.headers.map((header) => {
const sorted = header.column.getIsSorted();
const label = header.isPlaceholder ? null : <table.FlexRender header={header} />;
return (
<th
key={header.id}
scope="col"
colSpan={header.colSpan}
style={s.th}
aria-sort={
sorted === "asc"
? "ascending"
: sorted === "desc"
? "descending"
: undefined
}
>
{header.column.getCanSort() ? (
<button
type="button"
style={s.sort}
onClick={header.column.getToggleSortingHandler()}
>
{label}
{sorted ? SORT_MARK[sorted] : null}
</button>
) : (
label
)}
</th>
);
})}
</tr>
))}
</thead>
<tbody>
{padTop > 0 && (
<tr aria-hidden="true">
{/* oxlint-disable-next-line jsx-a11y/control-has-associated-label -- cellule de remplissage de virtualisation (ligne aria-hidden, sans contenu ni contrôle) */}
<td colSpan={columnCount} style={{ height: padTop, padding: 0 }} />
</tr>
)}
{visible.map((row) => (
<tr
key={row.id}
data-row-id={row.id}
onClick={onRowClick ? () => 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
<td key={cell.id} style={s.td}>
<table.FlexRender cell={cell} />
</td>
))}
</tr>
))}
{padBottom > 0 && (
<tr aria-hidden="true">
{/* oxlint-disable-next-line jsx-a11y/control-has-associated-label -- cellule de remplissage de virtualisation (ligne aria-hidden, sans contenu ni contrôle) */}
<td colSpan={columnCount} style={{ height: padBottom, padding: 0 }} />
</tr>
)}
{rows.length === 0 && (
<tr>
<td colSpan={columnCount} style={{ ...s.td, textAlign: "center" }}>
{emptyLabel}
</td>
</tr>
)}
</tbody>
</table>
</div>
{!virtual && table.getPageCount() > 1 && (
<nav style={s.pager} aria-label="Pagination">
<button
type="button"
style={s.button}
disabled={!table.getCanPreviousPage()}
onClick={() => table.previousPage()}
>
Previous
</button>
<span>
{table.store.state.pagination.pageIndex + 1} / {table.getPageCount()}
</span>
<button
type="button"
style={s.button}
disabled={!table.getCanNextPage()}
onClick={() => table.nextPage()}
>
Next
</button>
</nav>
)}
</div>
);
}
+109
View File
@@ -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<T> {
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<T>(props: VirtualListProps<T>) {
const {
items,
renderItem,
height,
estimateSize = 56,
overscan = 6,
getKey,
gap = 0,
onEndReached,
className,
style,
} = props;
const scrollRef = useRef<HTMLDivElement>(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 (
<div
ref={scrollRef}
className={className}
data-m3-virtual-list=""
style={{
height,
overflow: "auto",
contain: "strict",
background: "var(--md-sys-color-surface)",
color: "var(--md-sys-color-on-surface)",
...style,
}}
>
<ul
aria-label={props["aria-label"]}
style={{
position: "relative",
width: "100%",
height: virtualizer.getTotalSize(),
margin: 0,
padding: 0,
listStyle: "none",
}}
>
{virtualItems.map((item) => (
<li
key={item.key}
aria-setsize={items.length}
aria-posinset={item.index + 1}
data-index={item.index}
ref={virtualizer.measureElement}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
margin: 0,
listStyle: "none",
transform: `translateY(${item.start}px)`,
}}
>
{renderItem(items[item.index]!, item.index)}
</li>
))}
</ul>
</div>
);
}
+6
View File
@@ -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";
+416
View File
@@ -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<string, string>;
interface Resolved {
tag: string;
name: string;
file: string;
className: string;
events: EventMap;
}
interface Unresolved {
tag: string;
reason: string;
}
const defs: Map<string, Def[]> = new Map();
async function walk(dir: string): Promise<void> {
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<string>();
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/<dir>/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<string, EventMap> = {
// ── 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<string, Resolved[]> = 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<number>[] = [];
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 <Name>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 */
}
+66
View File
@@ -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';
+26
View File
@@ -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 };
}
+121
View File
@@ -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
+106
View File
@@ -0,0 +1,106 @@
{
"name": "@aphrody/m3-react",
"version": "1.4.4",
"description": "React wrappers (@lit/react createComponent) for every <md-*> 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"
}
}
+26
View File
@@ -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
* `<md-outlined-text-field>` / `<md-filled-text-field>` 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";
+280
View File
@@ -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 `<md-outlined-text-field>`
* / `<md-filled-text-field>` (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<TextFieldStateListener>();
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 `<input>`/`<textarea>` and an `<md-*-text-field>`
* host (which delegates these members to its inner control) satisfy it, so the
* binding helper does not need to import the React wrapper or the Lit element.
*/
export interface SelectionCapableElement {
value: string;
selectionStart: number | null;
selectionEnd: number | null;
setSelectionRange?(start: number, end: number): void;
}
/**
* Push the current {@link WebTextFieldState} value + selection onto a live
* `<md-*-text-field>` (or raw input) element, preserving the caret/selection
* inside the Lit Shadow DOM. Safe to call from a React effect after each
* commit; it writes `value` only when it differs to avoid clobbering IME
* composition, then restores the selection range.
*
* @returns `true` if anything was written, `false` if already in sync.
*/
export function bindTextFieldState(
state: WebTextFieldState,
element: SelectionCapableElement | null | undefined,
): boolean {
if (!element) {
return false;
}
let wrote = false;
if (element.value !== state.text) {
element.value = state.text;
wrote = true;
}
const { start, end } = state.selection;
if (element.selectionStart !== start || element.selectionEnd !== end) {
if (typeof element.setSelectionRange === "function") {
element.setSelectionRange(start, end);
} else {
element.selectionStart = start;
element.selectionEnd = end;
}
wrote = true;
}
return wrote;
}
/**
* Read a `<md-*-text-field>` (or raw input) element's current value + selection
* back into a {@link WebTextFieldState} as a single atomic edit. Use this as the
* `onInput`/`onSelect` handler bridge so user typing stays the source of truth.
*/
export function syncFromElement(
state: WebTextFieldState,
element: SelectionCapableElement | null | undefined,
): void {
if (!element) {
return;
}
const caretStart = element.selectionStart;
const caretEnd = element.selectionEnd;
state.edit((buffer) => {
buffer.text = element.value;
const start = caretStart ?? buffer.text.length;
const end = caretEnd ?? start;
buffer.selection = { start, end };
});
}
+69
View File
@@ -0,0 +1,69 @@
// Adaptive layout hooks: M3 window size class boundaries + the React hook
// rendering through the happy-dom harness.
import { afterEach, describe, expect, test } from "bun:test";
import * as React from "react";
import { createRoot, type Root } from "react-dom/client";
import { flushSync } from "react-dom";
import {
BREAKPOINTS,
classifyWidth,
minWidthQuery,
useWindowSizeClass,
} from "../adaptive/index.js";
describe("classifyWidth — canonical M3 boundaries (600/840/1200/1600)", () => {
test.each([
[0, "compact"],
[599, "compact"],
[600, "medium"],
[839, "medium"],
[840, "expanded"],
[1199, "expanded"],
[1200, "large"],
[1599, "large"],
[1600, "extra-large"],
[2560, "extra-large"],
] as const)("%i → %s", (w, cls) => {
expect(classifyWidth(w)).toBe(cls);
});
test("BREAKPOINTS lower-bounds match the spec", () => {
expect(BREAKPOINTS).toMatchObject({
compact: 0,
medium: 600,
expanded: 840,
large: 1200,
"extra-large": 1600,
});
});
test("minWidthQuery builds a CSS media query", () => {
expect(minWidthQuery("expanded")).toBe("(min-width: 840px)");
});
});
function Probe() {
return React.createElement("span", { "data-testid": "size-class" }, useWindowSizeClass());
}
describe("useWindowSizeClass", () => {
let root: Root | undefined;
let host: HTMLElement | undefined;
afterEach(() => {
flushSync(() => root?.unmount());
host?.remove();
root = undefined;
host = undefined;
});
test("renders a window size class and reflects the current viewport", () => {
host = document.createElement("div");
document.body.appendChild(host);
root = createRoot(host);
flushSync(() => root!.render(React.createElement(Probe)));
// After mount the effect ran: the class is one of the five valid values.
expect(["compact", "medium", "expanded", "large", "extra-large"]).toContain(
host.querySelector("span")?.textContent ?? "",
);
});
});
+63
View File
@@ -0,0 +1,63 @@
import { describe, expect, test } from "bun:test";
// Size budgets that lock in the tree-shaking guarantee: a deep family import
// must stay tiny, and the barrel must stay light. Built from source with React
// and @aphrody/material-web kept external (they resolve in the consumer / load the
// element code on demand), minified + gzipped — the figures a real consumer
// pays for the wrapper layer. Guards against accidental bloat or a regression
// of the per-family subpath split.
const EXTERNAL = [
"react",
"react-dom",
"react/jsx-runtime",
"@lit/react",
"@aphrody/material-web",
"@aphrody/material-web/*",
];
import { resolve } from "node:path";
async function gzipBytes(entry: string): Promise<number> {
const result = await Bun.build({
entrypoints: [resolve(import.meta.dir, "..", entry)],
external: EXTERNAL,
minify: true,
target: "browser",
});
if (!result.success) {
throw new Error(`build failed for ${entry}: ${result.logs.map((l) => l.message).join("; ")}`);
}
const sizes = await Promise.all(
result.outputs.map(async (output) => Bun.gzipSync(Buffer.from(await output.text())).length),
);
return sizes.reduce((total, size) => total + size, 0);
}
describe("bundle-size budgets (gzip)", () => {
test("a single family deep-import stays under 1 KB", async () => {
const size = await gzipBytes("wrappers/button.ts");
expect(size, `wrappers/button.ts = ${size} B gzip`).toBeLessThan(1024);
});
test("a chart family deep-import stays under 1.5 KB", async () => {
const size = await gzipBytes("wrappers/charts.ts");
expect(size, `wrappers/charts.ts = ${size} B gzip`).toBeLessThan(1536);
});
test("the full barrel (all 115 wrappers) stays under 7 KB", async () => {
const size = await gzipBytes("index.ts");
expect(size, `index.ts = ${size} B gzip`).toBeLessThan(7168);
});
test("the transitions entry stays under 2.5 KB", async () => {
const size = await gzipBytes("transitions/index.ts");
expect(size, `transitions/index.ts = ${size} B gzip`).toBeLessThan(2560);
});
test("a deep family import is at least 5x lighter than the barrel", async () => {
const family = await gzipBytes("wrappers/button.ts");
const barrel = await gzipBytes("index.ts");
expect(barrel / family).toBeGreaterThan(5);
});
});
+123
View File
@@ -0,0 +1,123 @@
// SPDX-License-Identifier: Apache-2.0
import { describe, expect, test } from "bun:test";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { renderToString } from "react-dom/server";
import { DataTable, dataColumns, VirtualList } from "../data/index.ts";
interface Person {
id: string;
name: string;
age: number;
}
const people: Person[] = Array.from({ length: 60 }, (_, i) => ({
id: `p${i}`,
name: `Person ${String(i).padStart(2, "0")}`,
age: 20 + (i % 40),
}));
const col = dataColumns<Person>();
const columns = [col.accessor("name", { header: "Name" }), col.accessor("age", { header: "Age" })];
const cellTexts = (html: string) => [...html.matchAll(/<td[^>]*>([^<]*)<\/td>/g)].map((m) => m[1]);
describe("DataTable", () => {
test("renders a semantic table with headers, a caption and the first page", () => {
const html = renderToString(
<DataTable
data={people}
columns={columns}
caption="People"
pageSize={10}
getRowId={(p) => p.id}
/>,
);
expect(html).toContain("<caption");
expect(html).toContain("People");
expect(html).toContain('scope="col"');
expect(html.match(/data-row-id=/g)?.length).toBe(10);
expect(cellTexts(html)[0]).toBe("Person 00");
expect(html).toMatch(/1(<!-- -->)? \/ (<!-- -->)?6/);
});
test("initial sorting orders rows and marks the header", () => {
const html = renderToString(
<DataTable
data={people}
columns={columns}
pageSize={5}
initialSorting={[{ id: "name", desc: true }]}
/>,
);
expect(cellTexts(html)[0]).toBe("Person 59");
expect(html).toContain('aria-sort="descending"');
});
test("virtual mode renders a window of rows with spacers and no pager", () => {
const html = renderToString(
<DataTable
data={people}
columns={columns}
virtual={{ height: 260, rowHeight: 52, overscan: 0 }}
/>,
);
const rendered = html.match(/data-row-id=/g)?.length ?? 0;
expect(rendered).toBeGreaterThan(0);
expect(rendered).toBeLessThan(people.length);
expect(html).toContain('aria-rowcount="61"');
expect(html).not.toContain('aria-label="Pagination"');
});
test("an empty table shows the empty label", () => {
expect(renderToString(<DataTable data={[]} columns={columns} emptyLabel="Nobody" />)).toContain(
"Nobody",
);
});
test("clicking a header sorts, the search field filters", async () => {
const container = document.createElement("div");
document.body.append(container);
const root = createRoot(container);
await act(async () =>
root.render(<DataTable data={people} columns={columns} searchable pageSize={100} />),
);
const firstName = () => container.querySelector("tbody td")?.textContent;
expect(firstName()).toBe("Person 00");
const sortName = container.querySelector<HTMLButtonElement>("th button")!;
await act(async () => sortName.click());
await act(async () => sortName.click());
expect(firstName()).toBe("Person 59");
const search = container.querySelector<HTMLInputElement>('input[type="search"]')!;
await act(async () => {
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!;
setter.call(search, "Person 42");
search.dispatchEvent(new Event("input", { bubbles: true }));
});
expect(container.querySelectorAll("tbody tr[data-row-id]").length).toBe(1);
expect(firstName()).toBe("Person 42");
await act(async () => root.unmount());
container.remove();
});
});
describe("VirtualList", () => {
test("renders only the visible window of a long list as listitems", () => {
const items = Array.from({ length: 1000 }, (_, i) => `Item ${i}`);
const html = renderToString(
<VirtualList
items={items}
height={280}
estimateSize={56}
overscan={2}
renderItem={(item) => <span>{item}</span>}
/>,
);
const rendered = html.match(/<li[\s>]/g)?.length ?? 0;
expect(rendered).toBeGreaterThan(0);
expect(rendered).toBeLessThanOrEqual(10);
expect(html.match(/<ul[\s>]/g)?.length).toBe(1);
expect(html).toContain('aria-setsize="1000"');
expect(html).toContain("Item 0");
expect(html).not.toContain("Item 999");
});
});
+107
View File
@@ -0,0 +1,107 @@
// SPDX-License-Identifier: Apache-2.0
import { afterEach, describe, expect, mock, test } from "bun:test";
import * as React from "react";
import { createRoot, type Root } from "react-dom/client";
import { flushSync } from "react-dom";
import { Fade, Collapse } from "../transitions/index.ts";
import { useTransitionState } from "../transitions/use-transition-state.ts";
let root: Root | undefined;
let host: HTMLElement | undefined;
const originalMatchMedia = window.matchMedia;
afterEach(() => {
if (root) flushSync(() => root!.unmount());
host?.remove();
root = undefined;
host = undefined;
window.matchMedia = originalMatchMedia;
});
function render(element: React.ReactNode) {
host = document.createElement("div");
document.body.append(host);
root = createRoot(host);
flushSync(() => root!.render(element));
return host;
}
describe("production transition lifecycle", () => {
test("does not start an enter lifecycle on an already visible mount without appear", async () => {
const entered = mock(() => {});
function Probe() {
const { phase } = useTransitionState(true, {
enterTimeout: 1,
exitTimeout: 1,
onEntered: entered,
});
return <output>{phase}</output>;
}
const el = render(<Probe />);
await Bun.sleep(40);
expect(el.textContent).toBe("entered");
expect(entered).not.toHaveBeenCalled();
});
test("cancels an interrupted enter without a stale completion callback", async () => {
const entered = mock(() => {});
const exited = mock(() => {});
function Probe({ shown }: { shown: boolean }) {
const { phase } = useTransitionState(shown, {
enterTimeout: 80,
exitTimeout: 5,
onEntered: entered,
onExited: exited,
});
return <output>{phase}</output>;
}
render(<Probe shown={false} />);
flushSync(() => root!.render(<Probe shown />));
await Bun.sleep(25);
flushSync(() => root!.render(<Probe shown={false} />));
await Bun.sleep(100);
expect(entered).not.toHaveBeenCalled();
expect(exited).toHaveBeenCalledTimes(1);
expect(host!.textContent).toBe("exited");
});
test("hidden preserved children cannot receive keyboard focus", () => {
const el = render(
<>
<Fade in={false}>
<button type="button" data-testid="fade">
Hidden
</button>
</Fade>
<Collapse in={false}>
<button type="button" data-testid="collapse">
Hidden
</button>
</Collapse>
</>,
);
expect(el.querySelector('[data-testid="fade"]')!.hasAttribute("inert")).toBe(true);
expect(el.querySelector('[data-testid="fade"]')!.getAttribute("aria-hidden")).toBe("true");
expect(el.querySelector('[data-testid="collapse"]')!.closest("[inert]")).not.toBeNull();
});
test("reduced motion removes CSS duration and unmounts exits immediately", () => {
const media = new EventTarget() as MediaQueryList;
Object.defineProperty(media, "matches", { value: true });
window.matchMedia = () => media;
const el = render(
<Fade in unmountOnExit>
<button type="button">Visible</button>
</Fade>,
);
expect(el.querySelector("button")!.style.transition).toContain("0ms");
flushSync(() =>
root!.render(
<Fade in={false} unmountOnExit>
<button type="button">Visible</button>
</Fade>,
),
);
expect(el.querySelector("button")).toBeNull();
});
});
+46
View File
@@ -0,0 +1,46 @@
// SPDX-License-Identifier: Apache-2.0
import { describe, expect, it } from 'bun:test';
import {
mapTanStackColumnsToM3,
mapM3SortToTanStack,
mapTanStackSortToM3,
} from '../adapters/tanstack';
describe('TanStack Table Adapter for M3', () => {
it('maps TanStack column definitions to M3 table columns', () => {
const tanstackCols = [
{ id: 'name', header: 'Dessert Name', enableSorting: true },
{ id: 'calories', header: 'Calories', enableSorting: false, meta: { numeric: true } },
{ accessorKey: 'fat', header: 'Fat (g)', meta: { filter: 'number' as const, width: '120px' } },
];
const m3Cols = mapTanStackColumnsToM3(tanstackCols);
expect(m3Cols).toHaveLength(3);
expect(m3Cols[0]).toEqual({
key: 'name',
label: 'Dessert Name',
sortable: true,
numeric: false,
filter: '',
resizable: false,
width: undefined,
});
expect(m3Cols[1].numeric).toBe(true);
expect(m3Cols[1].sortable).toBe(false);
expect(m3Cols[2].filter).toBe('number');
expect(m3Cols[2].width).toBe('120px');
});
it('bi-directionally converts sorting state', () => {
const tanstackSort = [{ id: 'calories', desc: true }, { id: 'name', desc: false }];
const m3Sort = mapTanStackSortToM3(tanstackSort);
expect(m3Sort).toEqual([
{ key: 'calories', direction: 'desc' },
{ key: 'name', direction: 'asc' },
]);
const backToTanstack = mapM3SortToTanStack(m3Sort);
expect(backToTanstack).toEqual(tanstackSort);
});
});
+140
View File
@@ -0,0 +1,140 @@
import { afterEach, describe, expect, test } from "bun:test";
import * as React from "react";
import { createRoot, type Root } from "react-dom/client";
import { flushSync } from "react-dom";
import { Collapse, Fade, Grow, Slide, Zoom } from "../transitions/index.js";
import { isVisible, resolveTimeout } from "../transitions/use-transition-state.js";
// --- Pure helpers (no DOM) -------------------------------------------------
describe("resolveTimeout", () => {
test("falls back when undefined", () => {
expect(resolveTimeout(undefined, "enter", 250)).toBe(250);
});
test("a single number applies to every direction", () => {
expect(resolveTimeout(300, "enter", 250)).toBe(300);
expect(resolveTimeout(300, "exit", 200)).toBe(300);
expect(resolveTimeout(300, "appear", 250)).toBe(300);
});
test("per-direction object resolves each direction", () => {
const t = { appear: 10, enter: 20, exit: 30 };
expect(resolveTimeout(t, "appear", 0)).toBe(10);
expect(resolveTimeout(t, "enter", 0)).toBe(20);
expect(resolveTimeout(t, "exit", 0)).toBe(30);
});
test("appear falls back to enter, then to the fallback", () => {
expect(resolveTimeout({ enter: 20 }, "appear", 99)).toBe(20);
expect(resolveTimeout({ exit: 30 }, "appear", 99)).toBe(99);
});
});
describe("isVisible", () => {
test("entering/entered are visible; exiting/exited are not", () => {
expect(isVisible("entering")).toBe(true);
expect(isVisible("entered")).toBe(true);
expect(isVisible("exiting")).toBe(false);
expect(isVisible("exited")).toBe(false);
});
});
// --- Component rendering (happy-dom + flushSync) ---------------------------
let root: Root | null = null;
let host: HTMLElement | null = null;
function render(element: React.ReactElement): HTMLElement {
host = document.createElement("div");
document.body.appendChild(host);
root = createRoot(host);
flushSync(() => root!.render(element));
return host;
}
afterEach(() => {
if (root) flushSync(() => root!.unmount());
host?.remove();
root = null;
host = null;
});
describe("Fade", () => {
test("shown: renders the child with opacity 1 and an opacity transition", () => {
const el = render(
<Fade in>
<div data-testid="box">hi</div>
</Fade>,
);
const box = el.querySelector<HTMLElement>('[data-testid="box"]');
expect(box).not.toBeNull();
expect(box!.style.opacity).toBe("1");
expect(box!.style.transition).toContain("opacity");
});
test("hidden (kept mounted): renders the child with opacity 0", () => {
const el = render(
<Fade in={false}>
<div data-testid="box">hi</div>
</Fade>,
);
const box = el.querySelector<HTMLElement>('[data-testid="box"]');
expect(box).not.toBeNull();
expect(box!.style.opacity).toBe("0");
});
test("mountOnEnter + not in: renders nothing", () => {
const el = render(
<Fade in={false} mountOnEnter>
<div data-testid="box">hi</div>
</Fade>,
);
expect(el.querySelector('[data-testid="box"]')).toBeNull();
});
test("preserves the child's own inline style", () => {
const el = render(
<Fade in>
<div data-testid="box" style={{ color: "red" }}>
hi
</div>
</Fade>,
);
const box = el.querySelector<HTMLElement>('[data-testid="box"]');
expect(box!.style.color).toBe("red");
expect(box!.style.opacity).toBe("1");
});
});
describe("Grow / Zoom / Slide / Collapse", () => {
test("each renders its child when shown and applies a transition", () => {
for (const T of [Grow, Zoom, Slide, Collapse]) {
const el = render(
<T in>
<div data-testid="box">x</div>
</T>,
);
const box = el.querySelector<HTMLElement>('[data-testid="box"]');
expect(box).not.toBeNull();
// Either the child is cloned with a transition (Fade/Grow/Zoom/Slide)
// or it sits inside an animated wrapper (Collapse) — assert some
// rendered element carries a transition.
const animated = [...el.querySelectorAll<HTMLElement>("*")].some(
(n) => n.style.transition.length > 0,
);
expect(animated).toBe(true);
// reset between iterations
if (root) flushSync(() => root!.unmount());
host?.remove();
root = null;
host = null;
}
});
test("Slide/Collapse unmount nothing when shown but render null when mountOnEnter and not in", () => {
const el = render(
<Collapse in={false} mountOnEnter>
<div data-testid="box">x</div>
</Collapse>,
);
expect(el.querySelector('[data-testid="box"]')).toBeNull();
});
});
+260
View File
@@ -0,0 +1,260 @@
// SPDX-License-Identifier: Apache-2.0
import { afterEach, describe, expect, it } from "bun:test";
import { createRoot, type Root } from "react-dom/client";
import { flushSync } from "react-dom";
import { renderToStaticMarkup } from "react-dom/server";
import { createElement, type ReactElement } from "react";
import {
bindTextFieldState,
syncFromElement,
useTextFieldState,
WebTextFieldState,
type SelectionCapableElement,
} from "../state/use-text-field-state.js";
describe("WebTextFieldState (pure logic)", () => {
it("constructs with defaults", () => {
const state = new WebTextFieldState();
expect(state.text).toBe("");
expect(state.selection).toEqual({ start: 0, end: 0 });
});
it("clamps the initial selection to the text length", () => {
const state = new WebTextFieldState("abc", { start: 10, end: 99 });
expect(state.selection).toEqual({ start: 3, end: 3 });
});
it("edit() updates text and selection atomically", () => {
const state = new WebTextFieldState("ab");
let observed: { text: string; selStart: number } | null = null;
state.subscribe(() => {
observed = { text: state.text, selStart: state.selection.start };
});
state.edit((buffer) => {
buffer.text = "abXYZ";
buffer.selection = { start: 5, end: 5 };
});
expect(state.text).toBe("abXYZ");
expect(state.selection).toEqual({ start: 5, end: 5 });
// The listener must never observe a half-applied state.
expect(observed).toEqual({ text: "abXYZ", selStart: 5 });
});
it("edit() commits nothing when the block makes no change", () => {
const state = new WebTextFieldState("hi", { start: 1, end: 1 });
let calls = 0;
state.subscribe(() => {
calls++;
});
state.edit(() => {
/* no-op */
});
expect(calls).toBe(0);
expect(state.text).toBe("hi");
});
it("edit() leaves state untouched if the block throws", () => {
const state = new WebTextFieldState("safe", { start: 2, end: 2 });
expect(() => {
state.edit((buffer) => {
buffer.text = "corrupt";
throw new Error("boom");
});
}).toThrow("boom");
expect(state.text).toBe("safe");
expect(state.selection).toEqual({ start: 2, end: 2 });
});
it("setSelection() clamps and notifies only on change", () => {
const state = new WebTextFieldState("hello");
let calls = 0;
state.subscribe(() => {
calls++;
});
state.setSelection(1, 3);
expect(state.selection).toEqual({ start: 1, end: 3 });
expect(calls).toBe(1);
// Same range again -> no notification.
state.setSelection(1, 3);
expect(calls).toBe(1);
// Out of range -> clamped to text length.
state.setSelection(99, 100);
expect(state.selection).toEqual({ start: 5, end: 5 });
expect(calls).toBe(2);
});
it("setSelection() defaults end to start (caret)", () => {
const state = new WebTextFieldState("hello");
state.setSelection(2);
expect(state.selection).toEqual({ start: 2, end: 2 });
});
it("replaceSelection() inserts and moves the caret", () => {
const state = new WebTextFieldState("foobar", { start: 3, end: 6 });
state.replaceSelection("X");
expect(state.text).toBe("fooX");
expect(state.selection).toEqual({ start: 4, end: 4 });
});
it("subscribe() returns a working unsubscribe", () => {
const state = new WebTextFieldState();
let calls = 0;
const off = state.subscribe(() => {
calls++;
});
state.setText("a");
off();
state.setText("ab");
expect(calls).toBe(1);
});
it("getSnapshot() returns the current text for useSyncExternalStore", () => {
const state = new WebTextFieldState("snap");
expect(state.getSnapshot()).toBe("snap");
state.setText("snap2");
expect(state.getSnapshot()).toBe("snap2");
});
});
function makeElement(
value = "",
): SelectionCapableElement & { setSelectionRange(s: number, e: number): void } {
return {
value,
selectionStart: 0,
selectionEnd: 0,
setSelectionRange(start: number, end: number) {
this.selectionStart = start;
this.selectionEnd = end;
},
};
}
describe("bindTextFieldState / syncFromElement", () => {
it("writes value + selection onto the element", () => {
const state = new WebTextFieldState("hello", { start: 5, end: 5 });
const el = makeElement();
const wrote = bindTextFieldState(state, el);
expect(wrote).toBe(true);
expect(el.value).toBe("hello");
expect(el.selectionStart).toBe(5);
expect(el.selectionEnd).toBe(5);
});
it("is a no-op when element already matches", () => {
const state = new WebTextFieldState("hi", { start: 2, end: 2 });
const el = makeElement("hi");
el.selectionStart = 2;
el.selectionEnd = 2;
expect(bindTextFieldState(state, el)).toBe(false);
});
it("tolerates a null element", () => {
const state = new WebTextFieldState();
expect(bindTextFieldState(state, null)).toBe(false);
expect(() => syncFromElement(state, undefined)).not.toThrow();
});
it("syncFromElement() reads value + caret back atomically", () => {
const state = new WebTextFieldState();
const el = makeElement("typed");
el.selectionStart = 5;
el.selectionEnd = 5;
syncFromElement(state, el);
expect(state.text).toBe("typed");
expect(state.selection).toEqual({ start: 5, end: 5 });
});
});
describe("useTextFieldState (SSR render path)", () => {
it("is SSR-safe and seeds initial value + end caret", () => {
const captured: { state?: WebTextFieldState } = {};
function Probe() {
const state = useTextFieldState("init");
// oxlint-disable-next-line react/immutability -- harnais de test : le composant publie l'état rendu pour les assertions
captured.state = state;
return createElement("output", null, state.text);
}
// renderToStaticMarkup exercises the getServerSnapshot path (no DOM).
const markup = renderToStaticMarkup(createElement(Probe));
expect(markup).toContain("init");
expect(captured.state?.text).toBe("init");
expect(captured.state?.selection).toEqual({ start: 4, end: 4 });
});
});
// Client render path: drive React with flushSync + happy-dom, matching the
// package's existing harness (adaptive/transitions tests). The setup.ts preload
// sets IS_REACT_ACT_ENVIRONMENT = false, so we do NOT use act().
describe("useTextFieldState (client render path)", () => {
let root: Root | null = null;
let container: HTMLElement | null = null;
afterEach(() => {
if (root) {
flushSync(() => root?.unmount());
root = null;
}
container?.remove();
container = null;
});
function mount(element: ReactElement): void {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
flushSync(() => root?.render(element));
}
it("returns a stable instance and re-renders on committed text changes", () => {
let renders = 0;
const instances: WebTextFieldState[] = [];
const captured: { state?: WebTextFieldState } = {};
function Probe() {
renders++;
const state = useTextFieldState("init");
instances.push(state);
// oxlint-disable-next-line react/immutability -- harnais de test : le composant publie l'état rendu pour les assertions
captured.state = state;
return createElement("output", null, state.text);
}
mount(createElement(Probe));
expect(captured.state?.text).toBe("init");
expect(captured.state?.selection).toEqual({ start: 4, end: 4 });
expect(container?.querySelector("output")?.textContent).toBe("init");
const rendersAfterMount = renders;
flushSync(() => {
captured.state?.setText("changed");
});
expect(container?.querySelector("output")?.textContent).toBe("changed");
expect(renders).toBeGreaterThan(rendersAfterMount);
// Same instance across renders.
expect(new Set(instances).size).toBe(1);
});
it("does not re-render when an edit leaves the text unchanged", () => {
let renders = 0;
const captured: { state?: WebTextFieldState } = {};
function Probe() {
renders++;
const state = useTextFieldState("abc");
// oxlint-disable-next-line react/immutability -- harnais de test : le composant publie l'état rendu pour les assertions
captured.state = state;
return createElement("output", null, state.text);
}
mount(createElement(Probe));
const baseline = renders;
// Moving only the caret does not change the text snapshot -> no re-render.
flushSync(() => {
captured.state?.setSelection(1, 2);
});
expect(renders).toBe(baseline);
expect(captured.state?.selection).toEqual({ start: 1, end: 2 });
});
});
+72
View File
@@ -0,0 +1,72 @@
// Verifies the generated @lit/react wrappers actually render their `md-*`
// custom element through React — the core surface of the package. Uses the same
// happy-dom harness as the transitions tests (no React Testing Library). We
// assert what is deterministic headless: the wrapper mounts the correct,
// upgraded custom element with a populated shadow root, and forwards children
// to its slot. Property/event binding is exercised end-to-end by the aphrody-web gate
// (real Chromium) since @lit/react property timing is browser-specific.
// The ElementInternals polyfill (test/setup) lets form-associated elements upgrade.
// oxlint-disable-next-line import/no-unassigned-import -- side effect: installs the happy-dom DOM globals this test renders into
import "@aphrody/m3-config/test/dom";
import { afterEach, describe, expect, test } from "bun:test";
import * as React from "react";
import { createRoot, type Root } from "react-dom/client";
import { flushSync } from "react-dom";
import { MdFilledButton } from "../wrappers/button.js";
import { MdCheckbox } from "../wrappers/checkbox.js";
import { MdIcon } from "../wrappers/icon.js";
import { MdCard } from "../wrappers/card.js";
import { MdOutlinedTextField } from "../wrappers/textfield.js";
let root: Root | undefined;
let host: HTMLElement | undefined;
const tick = () => new Promise((r) => setTimeout(r, 0));
async function mount(node: React.ReactNode, tag: string): Promise<HTMLElement> {
host = document.createElement("div");
document.body.appendChild(host);
root = createRoot(host);
flushSync(() => root!.render(node));
await tick();
const el = host.querySelector(tag) as HTMLElement | null;
if (!el) throw new Error(`<${tag}> not rendered`);
await (el as unknown as { updateComplete?: Promise<unknown> }).updateComplete;
return el;
}
afterEach(() => {
flushSync(() => root?.unmount());
host?.remove();
root = undefined;
host = undefined;
});
describe("generated wrappers render their custom element", () => {
test("MdFilledButton mounts an upgraded <md-filled-button> with a shadow root", async () => {
const el = await mount(<MdFilledButton>Click</MdFilledButton>, "md-filled-button");
expect(customElements.get("md-filled-button")).toBeDefined();
expect(el.shadowRoot).toBeTruthy();
expect(el.shadowRoot!.childNodes.length).toBeGreaterThan(0);
});
test("MdCheckbox (form-associated, ElementInternals) upgrades and renders", async () => {
const el = await mount(<MdCheckbox />, "md-checkbox");
expect(el.shadowRoot).toBeTruthy();
// attachInternals() succeeded (form association) — proves the polyfill path.
expect(el.shadowRoot!.childNodes.length).toBeGreaterThan(0);
});
test("MdIcon forwards children to its slot", async () => {
const el = await mount(<MdIcon>home</MdIcon>, "md-icon");
expect(el.textContent).toContain("home");
});
test("MdCard and MdOutlinedTextField mount their elements", async () => {
expect((await mount(<MdCard />, "md-card")).shadowRoot).toBeTruthy();
expect(
(await mount(<MdOutlinedTextField />, "md-outlined-text-field")).shadowRoot,
).toBeTruthy();
});
});
+83
View File
@@ -0,0 +1,83 @@
"use client";
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import * as React from "react";
import { EASING } from "./create-transition.js";
import { BaseTransitionProps, useTransitionProps } from "./use-transition-state.js";
export interface CollapseProps extends BaseTransitionProps {
/** The axis along which to collapse. Defaults to `vertical` (height). */
orientation?: "vertical" | "horizontal";
/**
* The size to keep visible while collapsed (e.g. `'0px'` or `'40px'` for a
* peeking header). Defaults to `'0px'`.
*/
collapsedSize?: string;
}
/**
* Collapses its child along one axis by animating its measured size between
* `collapsedSize` and its natural extent. The MUI `Collapse` equivalent.
*
* The child is wrapped in a clipping container so that overflow is hidden
* during the animation. The natural size is measured from a live ref each
* commit, so dynamic content is handled.
*/
export function Collapse(props: CollapseProps): React.ReactElement | null {
const { orientation = "vertical", collapsedSize = "0px", children } = props;
const { mounted, phase, visible, durationMs } = useTransitionProps(props, 300, 250);
const innerRef = React.useRef<HTMLDivElement>(null);
const [naturalSize, setNaturalSize] = React.useState<number>(0);
const isHorizontal = orientation === "horizontal";
// Measure the child's natural extent before paint whenever it should be
// shown, so the enter animation has a concrete target size.
React.useLayoutEffect(() => {
const node = innerRef.current;
if (!node) {
return;
}
const measured = isHorizontal ? node.scrollWidth : node.scrollHeight;
setNaturalSize(measured);
// oxlint-disable-next-line react/exhaustive-effect-dependencies -- phase and children are re-measurement triggers: the natural size changes with them although the body does not read them
}, [isHorizontal, phase, children]);
if (!mounted) {
return null;
}
const sizeProp = isHorizontal ? "width" : "height";
// `entered` lets the content size itself naturally (responsive); the other
// phases use an explicit pixel size so the transition can interpolate.
const targetSize = phase === "entered" ? "auto" : visible ? `${naturalSize}px` : collapsedSize;
const containerStyle: React.CSSProperties = {
[sizeProp]: targetSize,
overflow: "hidden",
transition: `${sizeProp} ${durationMs}ms ${EASING.standard}`,
willChange: sizeProp,
};
const innerStyle: React.CSSProperties = isHorizontal
? { display: "inline-block", height: "100%" }
: {};
return (
<div
style={containerStyle}
inert={!visible && collapsedSize === "0px"}
aria-hidden={!visible && collapsedSize === "0px"}
>
<div ref={innerRef} style={innerStyle}>
{children}
</div>
</div>
);
}
+20
View File
@@ -0,0 +1,20 @@
"use client";
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { createTransition, EASING } from "./create-transition.js";
/**
* Fades its child in and out by animating `opacity`, using M3 motion tokens.
* The MUI `Fade` equivalent.
*/
export const Fade = createTransition("Fade", {
enter: { easing: EASING.standardDecelerate, fallbackMs: 250 },
exit: { easing: EASING.standardAccelerate, fallbackMs: 200 },
properties: ["opacity"],
style: ({ visible }) => ({ opacity: visible ? 1 : 0 }),
});
+24
View File
@@ -0,0 +1,24 @@
"use client";
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { createTransition, EASING } from "./create-transition.js";
/**
* Scales its child up from a small, faded state and fades/scales it back out.
* Combines opacity with a `transform: scale()`, like the MUI `Grow`.
*/
export const Grow = createTransition("Grow", {
enter: { easing: EASING.emphasizedDecelerate, fallbackMs: 300 },
exit: { easing: EASING.emphasizedAccelerate, fallbackMs: 200 },
properties: ["opacity", "transform"],
style: ({ visible, childStyle }) => ({
opacity: visible ? 1 : 0,
transform: visible ? "scale(1)" : "scale(0.75)",
transformOrigin: childStyle?.transformOrigin ?? "center center",
}),
});
+55
View File
@@ -0,0 +1,55 @@
"use client";
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { createTransition, EASING } from "./create-transition.js";
import type { BaseTransitionProps } from "./use-transition-state.js";
/** The edge the child slides in from. */
export type SlideDirection = "left" | "right" | "up" | "down";
export interface SlideProps extends BaseTransitionProps {
/**
* The edge the child enters from (and exits toward). Defaults to `down`,
* i.e. the child starts below its resting position and slides up into view.
*/
direction?: SlideDirection;
/**
* The off-screen offset used while hidden. Accepts any CSS length; defaults
* to `100%` of the element's own size on the relevant axis.
*/
offset?: string;
}
function hiddenTransform(direction: SlideDirection, offset: string): string {
switch (direction) {
case "left":
return `translateX(${offset})`;
case "right":
return `translateX(-${offset})`;
case "up":
return `translateY(${offset})`;
case "down":
default:
return `translateY(-${offset})`;
}
}
/**
* Slides its child in from a screen edge and back out, animating `transform`.
* The MUI `Slide` equivalent.
*/
export const Slide = createTransition<SlideProps>("Slide", {
enter: { easing: EASING.emphasizedDecelerate, fallbackMs: 300 },
exit: { easing: EASING.emphasizedAccelerate, fallbackMs: 200 },
properties: ["transform"],
style: ({ visible, props }) => ({
transform: visible
? "translate(0, 0)"
: hiddenTransform(props.direction ?? "down", props.offset ?? "100%"),
}),
});
+23
View File
@@ -0,0 +1,23 @@
"use client";
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { createTransition, EASING } from "./create-transition.js";
/**
* Zooms its child in from scale 0 to its natural size and back out, animating
* only `transform`. The MUI `Zoom` equivalent.
*/
export const Zoom = createTransition("Zoom", {
enter: { easing: EASING.emphasizedDecelerate, fallbackMs: 250 },
exit: { easing: EASING.emphasizedAccelerate, fallbackMs: 200 },
properties: ["transform"],
style: ({ visible, childStyle }) => ({
transform: visible ? "scale(1)" : "scale(0)",
transformOrigin: childStyle?.transformOrigin ?? "center center",
}),
});
+73
View File
@@ -0,0 +1,73 @@
"use client";
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import * as React from "react";
import { BaseTransitionProps, useTransitionProps } from "./use-transition-state.js";
/** M3 easing tokens with their spec values as fallbacks. */
export const EASING = {
standard: "var(--md-sys-motion-easing-standard, cubic-bezier(0.2, 0, 0, 1))",
standardDecelerate: "var(--md-sys-motion-easing-standard-decelerate, cubic-bezier(0, 0, 0, 1))",
standardAccelerate: "var(--md-sys-motion-easing-standard-accelerate, cubic-bezier(0.3, 0, 1, 1))",
emphasizedDecelerate:
"var(--md-sys-motion-easing-emphasized-decelerate, cubic-bezier(0.05, 0.7, 0.1, 1))",
emphasizedAccelerate:
"var(--md-sys-motion-easing-emphasized-accelerate, cubic-bezier(0.3, 0, 0.8, 0.15))",
} as const;
/** How one style transition looks; everything else (state machine, cloning, timing) is shared. */
export interface TransitionSpec<P extends BaseTransitionProps> {
enter: { easing: string; fallbackMs: number };
exit: { easing: string; fallbackMs: number };
/** CSS properties that animate, in `transition` / `will-change` order. */
properties: readonly string[];
/** The values of those properties for the current visual state. */
style(state: {
visible: boolean;
childStyle: React.CSSProperties | undefined;
props: P;
}): React.CSSProperties;
}
/**
* Builds a transition component that clones its single child with an inline style: the visual
* state comes from `spec.style`, the `transition` and `will-change` declarations from the spec.
*/
export function createTransition<P extends BaseTransitionProps = BaseTransitionProps>(
name: string,
spec: TransitionSpec<P>,
): (props: P) => React.ReactElement | null {
const willChange = spec.properties.join(", ");
function Transition(props: P): React.ReactElement | null {
const { mounted, visible, durationMs } = useTransitionProps(
props,
spec.enter.fallbackMs,
spec.exit.fallbackMs,
);
if (!mounted) {
return null;
}
const easing = visible ? spec.enter.easing : spec.exit.easing;
const childStyle = (props.children.props as { style?: React.CSSProperties }).style;
const style: React.CSSProperties = {
...childStyle,
...spec.style({ visible, childStyle, props }),
transition: spec.properties.map((p) => `${p} ${durationMs}ms ${easing}`).join(", "),
willChange,
};
const childProps = props.children.props as React.HTMLAttributes<HTMLElement>;
return React.cloneElement(props.children, {
style,
inert: !visible || childProps.inert,
"aria-hidden": !visible || childProps["aria-hidden"],
} as React.HTMLAttributes<HTMLElement>);
}
Transition.displayName = name;
return Transition;
}
+20
View File
@@ -0,0 +1,20 @@
"use client";
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
export { Collapse } from "./Collapse.js";
export type { CollapseProps } from "./Collapse.js";
export { Fade } from "./Fade.js";
export { Grow } from "./Grow.js";
export { Slide } from "./Slide.js";
export type { SlideDirection, SlideProps } from "./Slide.js";
export { Zoom } from "./Zoom.js";
export type {
BaseTransitionProps,
TransitionPhase,
TransitionTimeout,
} from "./use-transition-state.js";
+233
View File
@@ -0,0 +1,233 @@
"use client";
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import * as React from "react";
/**
* Resolves an MUI-style `timeout` (a single duration or per-direction
* durations) to the millisecond value for the given direction.
*/
export type TransitionTimeout = number | { appear?: number; enter?: number; exit?: number };
export function resolveTimeout(
timeout: TransitionTimeout | undefined,
direction: "appear" | "enter" | "exit",
fallback: number,
): number {
if (timeout == null) {
return fallback;
}
if (typeof timeout === "number") {
return timeout;
}
if (direction === "appear") {
return timeout.appear ?? timeout.enter ?? fallback;
}
return timeout[direction] ?? fallback;
}
/** The discrete lifecycle phases of a transition. */
export type TransitionPhase = "entering" | "entered" | "exiting" | "exited";
/** Props shared by every transition component. */
export interface BaseTransitionProps {
/** Whether the child should be shown. */
in: boolean;
/**
* Duration in ms, or per-direction durations. When omitted, the component's
* M3 motion-token default is used.
*/
timeout?: TransitionTimeout;
/** Animate on the initial mount when `in` is already true. */
appear?: boolean;
/** Keep the child mounted while hidden instead of unmounting it. */
mountOnEnter?: boolean;
/** Unmount the child once it has finished exiting. */
unmountOnExit?: boolean;
/** The single React element to animate. */
children: React.ReactElement;
}
export interface UseTransitionStateResult {
/** Whether the child should currently be rendered in the tree. */
mounted: boolean;
/** The current lifecycle phase. */
phase: TransitionPhase;
/** Whether this is the very first commit (used to gate `appear`). */
isAppearing: boolean;
/** Call from the element's `onTransitionEnd` to settle the phase. */
handleTransitionEnd: () => void;
}
const REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)";
function subscribeReducedMotion(onChange: () => void): () => void {
if (typeof window === "undefined" || !window.matchMedia) return () => {};
const media = window.matchMedia(REDUCED_MOTION_QUERY);
media.addEventListener("change", onChange);
return () => media.removeEventListener("change", onChange);
}
function useReducedMotion(): boolean {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => typeof window !== "undefined" && !!window.matchMedia?.(REDUCED_MOTION_QUERY).matches,
() => false,
);
}
/**
* Drives mount/unmount and entering/entered/exiting/exited phases from the
* boolean `in` prop, using a small internal state machine. No external
* dependency. SSR-safe (effects run only on the client).
*/
export function useTransitionState(
inProp: boolean,
{
appear = false,
mountOnEnter = false,
unmountOnExit = false,
enterTimeout,
exitTimeout,
appearTimeout = enterTimeout,
onEntered,
onExited,
}: {
appear?: boolean;
mountOnEnter?: boolean;
unmountOnExit?: boolean;
enterTimeout: number;
exitTimeout: number;
appearTimeout?: number;
onEntered?: () => void;
onExited?: () => void;
},
): UseTransitionStateResult {
const firstRender = React.useRef(true);
const initialSteady = React.useRef(!inProp || !appear);
const initialIn = React.useRef(inProp);
const appearing = React.useRef(inProp && appear);
const callbacks = React.useRef({ onEntered, onExited });
callbacks.current = { onEntered, onExited };
const [phase, setPhase] = React.useState<TransitionPhase>(() => {
if (inProp) {
return appear ? "exited" : "entered";
}
return "exited";
});
// Whether the element is in the DOM at all.
const [mounted, setMounted] = React.useState(() => inProp || (!mountOnEnter && !unmountOnExit));
const timer = React.useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const settlement = React.useRef<(() => void) | undefined>(undefined);
const clear = () => {
if (timer.current !== undefined) {
clearTimeout(timer.current);
timer.current = undefined;
}
};
React.useEffect(() => {
const initial = firstRender.current;
firstRender.current = false;
// A steady initial state must not start a transition or fire its callback.
if (initialIn.current !== inProp) initialSteady.current = false;
if ((initial && (!inProp || !appear)) || initialSteady.current) return clear;
let cancelled = false;
let settled = false;
let raf: number | undefined;
const duration = inProp ? (appearing.current ? appearTimeout : enterTimeout) : exitTimeout;
const settle = () => {
if (cancelled || settled) return;
settled = true;
clear();
appearing.current = false;
setPhase(inProp ? "entered" : "exited");
if (!inProp && unmountOnExit) setMounted(false);
(inProp ? callbacks.current.onEntered : callbacks.current.onExited)?.();
};
settlement.current = settle;
clear();
if (inProp) {
setMounted(true);
if (duration <= 0) settle();
else {
// Commit the hidden state before entering so browsers can interpolate.
setPhase("exited");
raf = requestAnimationFrame(() => {
setPhase("entering");
timer.current = setTimeout(settle, duration);
});
}
} else {
appearing.current = false;
setPhase("exiting");
if (duration <= 0) settle();
else timer.current = setTimeout(settle, duration);
}
return () => {
cancelled = true;
settlement.current = undefined;
if (raf !== undefined) cancelAnimationFrame(raf);
clear();
};
// Callbacks are kept current without restarting an animation on every render.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [inProp, enterTimeout, exitTimeout, appearTimeout, unmountOnExit]);
const handleTransitionEnd = React.useCallback(() => {
if (phase === "entering" || phase === "exiting") settlement.current?.();
}, [phase]);
return { mounted, phase, isAppearing: appearing.current, handleTransitionEnd };
}
/** True while the element should be in its "shown" visual state. */
export function isVisible(phase: TransitionPhase): boolean {
return phase === "entering" || phase === "entered";
}
/**
* What every transition component derives from its props: the resolved enter/exit durations,
* the state machine and the current visual state. `durationMs` is the one of the running direction.
*/
export function useTransitionProps(
props: BaseTransitionProps,
enterFallbackMs: number,
exitFallbackMs: number,
) {
const {
in: inProp,
timeout,
appear = false,
mountOnEnter = false,
unmountOnExit = false,
} = props;
const reduced = useReducedMotion();
const enterMs = reduced ? 0 : resolveTimeout(timeout, "enter", enterFallbackMs);
const exitMs = reduced ? 0 : resolveTimeout(timeout, "exit", exitFallbackMs);
const appearMs = reduced ? 0 : resolveTimeout(timeout, "appear", enterFallbackMs);
const { mounted, phase, isAppearing } = useTransitionState(inProp, {
appear,
mountOnEnter,
unmountOnExit,
enterTimeout: enterMs,
exitTimeout: exitMs,
appearTimeout: appearMs,
});
const resolvedPhase = reduced ? (inProp ? "entered" : "exited") : phase;
const visible = isVisible(resolvedPhase);
return {
mounted: reduced ? inProp || (!mountOnEnter && !unmountOnExit) : mounted,
phase: resolvedPhase,
visible,
durationMs: isAppearing ? appearMs : visible ? enterMs : exitMs,
};
}
+27
View File
@@ -0,0 +1,27 @@
{
"extends": [
"./tsconfig.json",
"@aphrody/m3-config/tsconfig.build.json"
],
"compilerOptions": {
"outDir": "dist",
"rootDir": "."
},
"include": [
"index.ts",
"wrappers/**/*.ts",
"transitions/**/*.ts",
"transitions/**/*.tsx",
"adaptive/**/*.ts",
"state/**/*.ts",
"adapters/**/*.ts",
"data/**/*.ts",
"data/**/*.tsx",
"material-web-shims.d.ts"
],
"exclude": [
"node_modules",
"generate.ts",
"dist"
]
}
+38
View File
@@ -0,0 +1,38 @@
{
"extends": "@aphrody/m3-config/tsconfig.json",
"compilerOptions": {
"lib": [
"ES2022",
"DOM",
"DOM.Iterable",
"ESNext.Disposable"
],
"experimentalDecorators": true,
"useDefineForClassFields": false,
"verbatimModuleSyntax": false,
"noUnusedLocals": false,
"noUnusedParameters": false,
"types": [
"react",
"react-dom"
]
},
"include": [
"index.ts",
"wrappers/**/*.ts",
"transitions/**/*.ts",
"transitions/**/*.tsx",
"adaptive/**/*.ts",
"state/**/*.ts",
"data/**/*.ts",
"data/**/*.tsx",
"*.ts",
"*.d.ts"
],
"exclude": [
"node_modules",
"dist",
"generate.ts",
"tsup.config.ts"
]
}
+13
View File
@@ -0,0 +1,13 @@
// AUTO-GENERATED by generate.ts — do not edit by hand. Run `bun run generate`.
import * as React from 'react';
import {createComponent} from '@lit/react';
import {MdAlert as MdAlertElement} from '@aphrody/material-web/alert/alert.js';
export const MdAlert = createComponent({
react: React,
tagName: 'md-alert',
elementClass: MdAlertElement,
events: {onClose: 'close'},
});
+21
View File
@@ -0,0 +1,21 @@
// AUTO-GENERATED by generate.ts — do not edit by hand. Run `bun run generate`.
import * as React from 'react';
import {createComponent} from '@lit/react';
import {MdBottomAppBar as MdBottomAppBarElement} from '@aphrody/material-web/appbar/bottom-app-bar.js';
import {MdTopAppBar as MdTopAppBarElement} from '@aphrody/material-web/appbar/top-app-bar.js';
export const MdBottomAppBar = createComponent({
react: React,
tagName: 'md-bottom-app-bar',
elementClass: MdBottomAppBarElement,
events: {},
});
export const MdTopAppBar = createComponent({
react: React,
tagName: 'md-top-app-bar',
elementClass: MdTopAppBarElement,
events: {},
});
+13
View File
@@ -0,0 +1,13 @@
// AUTO-GENERATED by generate.ts — do not edit by hand. Run `bun run generate`.
import * as React from 'react';
import {createComponent} from '@lit/react';
import {MdAutocomplete as MdAutocompleteElement} from '@aphrody/material-web/autocomplete/autocomplete.js';
export const MdAutocomplete = createComponent({
react: React,
tagName: 'md-autocomplete',
elementClass: MdAutocompleteElement,
events: {onInput: 'input', onSelect: 'autocomplete:select'},
});
+21
View File
@@ -0,0 +1,21 @@
// AUTO-GENERATED by generate.ts — do not edit by hand. Run `bun run generate`.
import * as React from 'react';
import {createComponent} from '@lit/react';
import {MdAvatar as MdAvatarElement} from '@aphrody/material-web/avatar/avatar.js';
import {MdAvatarGroup as MdAvatarGroupElement} from '@aphrody/material-web/avatar/avatar-group.js';
export const MdAvatar = createComponent({
react: React,
tagName: 'md-avatar',
elementClass: MdAvatarElement,
events: {onError: 'error'},
});
export const MdAvatarGroup = createComponent({
react: React,
tagName: 'md-avatar-group',
elementClass: MdAvatarGroupElement,
events: {},
});
+13
View File
@@ -0,0 +1,13 @@
// AUTO-GENERATED by generate.ts — do not edit by hand. Run `bun run generate`.
import * as React from 'react';
import {createComponent} from '@lit/react';
import {MdBackdrop as MdBackdropElement} from '@aphrody/material-web/backdrop/backdrop.js';
export const MdBackdrop = createComponent({
react: React,
tagName: 'md-backdrop',
elementClass: MdBackdropElement,
events: {},
});
+13
View File
@@ -0,0 +1,13 @@
// AUTO-GENERATED by generate.ts — do not edit by hand. Run `bun run generate`.
import * as React from 'react';
import {createComponent} from '@lit/react';
import {MdBadge as MdBadgeElement} from '@aphrody/material-web/badge/badge.js';
export const MdBadge = createComponent({
react: React,
tagName: 'md-badge',
elementClass: MdBadgeElement,
events: {},
});
+13
View File
@@ -0,0 +1,13 @@
// AUTO-GENERATED by generate.ts — do not edit by hand. Run `bun run generate`.
import * as React from 'react';
import {createComponent} from '@lit/react';
import {MdBanner as MdBannerElement} from '@aphrody/material-web/banner/banner.js';
export const MdBanner = createComponent({
react: React,
tagName: 'md-banner',
elementClass: MdBannerElement,
events: {onClose: 'close'},
});
+13
View File
@@ -0,0 +1,13 @@
// AUTO-GENERATED by generate.ts — do not edit by hand. Run `bun run generate`.
import * as React from 'react';
import {createComponent} from '@lit/react';
import {MdBreadcrumbs as MdBreadcrumbsElement} from '@aphrody/material-web/breadcrumbs/breadcrumbs.js';
export const MdBreadcrumbs = createComponent({
react: React,
tagName: 'md-breadcrumbs',
elementClass: MdBreadcrumbsElement,
events: {},
});
+45
View File
@@ -0,0 +1,45 @@
// AUTO-GENERATED by generate.ts — do not edit by hand. Run `bun run generate`.
import * as React from 'react';
import {createComponent} from '@lit/react';
import {MdElevatedButton as MdElevatedButtonElement} from '@aphrody/material-web/button/elevated-button.js';
import {MdFilledButton as MdFilledButtonElement} from '@aphrody/material-web/button/filled-button.js';
import {MdFilledTonalButton as MdFilledTonalButtonElement} from '@aphrody/material-web/button/filled-tonal-button.js';
import {MdOutlinedButton as MdOutlinedButtonElement} from '@aphrody/material-web/button/outlined-button.js';
import {MdTextButton as MdTextButtonElement} from '@aphrody/material-web/button/text-button.js';
export const MdElevatedButton = createComponent({
react: React,
tagName: 'md-elevated-button',
elementClass: MdElevatedButtonElement,
events: {},
});
export const MdFilledButton = createComponent({
react: React,
tagName: 'md-filled-button',
elementClass: MdFilledButtonElement,
events: {},
});
export const MdFilledTonalButton = createComponent({
react: React,
tagName: 'md-filled-tonal-button',
elementClass: MdFilledTonalButtonElement,
events: {},
});
export const MdOutlinedButton = createComponent({
react: React,
tagName: 'md-outlined-button',
elementClass: MdOutlinedButtonElement,
events: {},
});
export const MdTextButton = createComponent({
react: React,
tagName: 'md-text-button',
elementClass: MdTextButtonElement,
events: {},
});
+13
View File
@@ -0,0 +1,13 @@
// AUTO-GENERATED by generate.ts — do not edit by hand. Run `bun run generate`.
import * as React from 'react';
import {createComponent} from '@lit/react';
import {MdButtonGroup as MdButtonGroupElement} from '@aphrody/material-web/buttongroup/button-group.js';
export const MdButtonGroup = createComponent({
react: React,
tagName: 'md-button-group',
elementClass: MdButtonGroupElement,
events: {onChange: 'button-group:change'},
});
+13
View File
@@ -0,0 +1,13 @@
// AUTO-GENERATED by generate.ts — do not edit by hand. Run `bun run generate`.
import * as React from 'react';
import {createComponent} from '@lit/react';
import {M3Canvas3D as Md3dCanvasElement} from '@aphrody/material-web/canvas3d/canvas-3d.js';
export const Md3dCanvas = createComponent({
react: React,
tagName: 'md-3d-canvas',
elementClass: Md3dCanvasElement,
events: {},
});
+13
View File
@@ -0,0 +1,13 @@
// AUTO-GENERATED by generate.ts — do not edit by hand. Run `bun run generate`.
import * as React from 'react';
import {createComponent} from '@lit/react';
import {MdCard as MdCardElement} from '@aphrody/material-web/card/card.js';
export const MdCard = createComponent({
react: React,
tagName: 'md-card',
elementClass: MdCardElement,
events: {},
});
+13
View File
@@ -0,0 +1,13 @@
// AUTO-GENERATED by generate.ts — do not edit by hand. Run `bun run generate`.
import * as React from 'react';
import {createComponent} from '@lit/react';
import {M3Card3D as Md3dCardElement} from '@aphrody/material-web/card3d/card-3d.js';
export const Md3dCard = createComponent({
react: React,
tagName: 'md-3d-card',
elementClass: Md3dCardElement,
events: {},
});
+21
View File
@@ -0,0 +1,21 @@
// AUTO-GENERATED by generate.ts — do not edit by hand. Run `bun run generate`.
import * as React from 'react';
import {createComponent} from '@lit/react';
import {MdCarousel as MdCarouselElement} from '@aphrody/material-web/carousel/carousel.js';
import {MdCarouselItem as MdCarouselItemElement} from '@aphrody/material-web/carousel/carousel-item.js';
export const MdCarousel = createComponent({
react: React,
tagName: 'md-carousel',
elementClass: MdCarouselElement,
events: {onChange: 'carousel-change'},
});
export const MdCarouselItem = createComponent({
react: React,
tagName: 'md-carousel-item',
elementClass: MdCarouselItemElement,
events: {},
});
+69
View File
@@ -0,0 +1,69 @@
// AUTO-GENERATED by generate.ts — do not edit by hand. Run `bun run generate`.
import * as React from 'react';
import {createComponent} from '@lit/react';
import {MdLineChart as MdLineChartElement} from '@aphrody/material-web/charts/line-chart.js';
import {MdBarChart as MdBarChartElement} from '@aphrody/material-web/charts/bar-chart.js';
import {MdPieChart as MdPieChartElement} from '@aphrody/material-web/charts/pie-chart.js';
import {MdAreaChart as MdAreaChartElement} from '@aphrody/material-web/charts/area-chart.js';
import {MdSparkline as MdSparklineElement} from '@aphrody/material-web/charts/sparkline.js';
import {MdGauge as MdGaugeElement} from '@aphrody/material-web/charts/gauge.js';
import {MdScatterChart as MdScatterChartElement} from '@aphrody/material-web/charts/scatter-chart.js';
import {MdRadarChart as MdRadarChartElement} from '@aphrody/material-web/charts/radar-chart.js';
export const MdLineChart = createComponent({
react: React,
tagName: 'md-line-chart',
elementClass: MdLineChartElement,
events: {},
});
export const MdBarChart = createComponent({
react: React,
tagName: 'md-bar-chart',
elementClass: MdBarChartElement,
events: {},
});
export const MdPieChart = createComponent({
react: React,
tagName: 'md-pie-chart',
elementClass: MdPieChartElement,
events: {},
});
export const MdAreaChart = createComponent({
react: React,
tagName: 'md-area-chart',
elementClass: MdAreaChartElement,
events: {},
});
export const MdSparkline = createComponent({
react: React,
tagName: 'md-sparkline',
elementClass: MdSparklineElement,
events: {},
});
export const MdGauge = createComponent({
react: React,
tagName: 'md-gauge',
elementClass: MdGaugeElement,
events: {},
});
export const MdScatterChart = createComponent({
react: React,
tagName: 'md-scatter-chart',
elementClass: MdScatterChartElement,
events: {},
});
export const MdRadarChart = createComponent({
react: React,
tagName: 'md-radar-chart',
elementClass: MdRadarChartElement,
events: {},
});
+13
View File
@@ -0,0 +1,13 @@
// AUTO-GENERATED by generate.ts — do not edit by hand. Run `bun run generate`.
import * as React from 'react';
import {createComponent} from '@lit/react';
import {MdCheckbox as MdCheckboxElement} from '@aphrody/material-web/checkbox/checkbox.js';
export const MdCheckbox = createComponent({
react: React,
tagName: 'md-checkbox',
elementClass: MdCheckboxElement,
events: {onChange: 'change', onInput: 'input'},
});
+45
View File
@@ -0,0 +1,45 @@
// AUTO-GENERATED by generate.ts — do not edit by hand. Run `bun run generate`.
import * as React from 'react';
import {createComponent} from '@lit/react';
import {MdAssistChip as MdAssistChipElement} from '@aphrody/material-web/chips/assist-chip.js';
import {MdChipSet as MdChipSetElement} from '@aphrody/material-web/chips/chip-set.js';
import {MdFilterChip as MdFilterChipElement} from '@aphrody/material-web/chips/filter-chip.js';
import {MdInputChip as MdInputChipElement} from '@aphrody/material-web/chips/input-chip.js';
import {MdSuggestionChip as MdSuggestionChipElement} from '@aphrody/material-web/chips/suggestion-chip.js';
export const MdAssistChip = createComponent({
react: React,
tagName: 'md-assist-chip',
elementClass: MdAssistChipElement,
events: {},
});
export const MdChipSet = createComponent({
react: React,
tagName: 'md-chip-set',
elementClass: MdChipSetElement,
events: {},
});
export const MdFilterChip = createComponent({
react: React,
tagName: 'md-filter-chip',
elementClass: MdFilterChipElement,
events: {},
});
export const MdInputChip = createComponent({
react: React,
tagName: 'md-input-chip',
elementClass: MdInputChipElement,
events: {onRemove: 'remove', onUpdateFocus: 'update-focus'},
});
export const MdSuggestionChip = createComponent({
react: React,
tagName: 'md-suggestion-chip',
elementClass: MdSuggestionChipElement,
events: {},
});
+29
View File
@@ -0,0 +1,29 @@
// AUTO-GENERATED by generate.ts — do not edit by hand. Run `bun run generate`.
import * as React from 'react';
import {createComponent} from '@lit/react';
import {MdDatePicker as MdDatePickerElement} from '@aphrody/material-web/datepicker/date-picker.js';
import {MdDateRangePicker as MdDateRangePickerElement} from '@aphrody/material-web/datepicker/date-range-picker.js';
import {MdDateTimePicker as MdDateTimePickerElement} from '@aphrody/material-web/datepicker/date-time-picker.js';
export const MdDatePicker = createComponent({
react: React,
tagName: 'md-date-picker',
elementClass: MdDatePickerElement,
events: {onChange: 'date-picker:change'},
});
export const MdDateRangePicker = createComponent({
react: React,
tagName: 'md-date-range-picker',
elementClass: MdDateRangePickerElement,
events: {onInput: 'input', onChange: 'change', onDateRangeChange: 'date-range-picker:change'},
});
export const MdDateTimePicker = createComponent({
react: React,
tagName: 'md-date-time-picker',
elementClass: MdDateTimePickerElement,
events: {onInput: 'input', onChange: 'change', onDateTimeChange: 'date-time-picker:change'},
});
+13
View File
@@ -0,0 +1,13 @@
// AUTO-GENERATED by generate.ts — do not edit by hand. Run `bun run generate`.
import * as React from 'react';
import {createComponent} from '@lit/react';
import {MdDialog as MdDialogElement} from '@aphrody/material-web/dialog/dialog.js';
export const MdDialog = createComponent({
react: React,
tagName: 'md-dialog',
elementClass: MdDialogElement,
events: {onOpen: 'open', onOpened: 'opened', onClose: 'close', onClosed: 'closed', onCancel: 'cancel'},
});
+13
View File
@@ -0,0 +1,13 @@
// AUTO-GENERATED by generate.ts — do not edit by hand. Run `bun run generate`.
import * as React from 'react';
import {createComponent} from '@lit/react';
import {MdDivider as MdDividerElement} from '@aphrody/material-web/divider/divider.js';
export const MdDivider = createComponent({
react: React,
tagName: 'md-divider',
elementClass: MdDividerElement,
events: {},
});
+13
View File
@@ -0,0 +1,13 @@
// AUTO-GENERATED by generate.ts — do not edit by hand. Run `bun run generate`.
import * as React from 'react';
import {createComponent} from '@lit/react';
import {MdWebgpuCanvas as MdWebgpuCanvasElement} from '@aphrody/material-web/effects/webgpu-canvas.js';
export const MdWebgpuCanvas = createComponent({
react: React,
tagName: 'md-webgpu-canvas',
elementClass: MdWebgpuCanvasElement,
events: {},
});
+13
View File
@@ -0,0 +1,13 @@
// AUTO-GENERATED by generate.ts — do not edit by hand. Run `bun run generate`.
import * as React from 'react';
import {createComponent} from '@lit/react';
import {MdElevation as MdElevationElement} from '@aphrody/material-web/elevation/elevation.js';
export const MdElevation = createComponent({
react: React,
tagName: 'md-elevation',
elementClass: MdElevationElement,
events: {},
});
+21
View File
@@ -0,0 +1,21 @@
// AUTO-GENERATED by generate.ts — do not edit by hand. Run `bun run generate`.
import * as React from 'react';
import {createComponent} from '@lit/react';
import {MdAccordion as MdAccordionElement} from '@aphrody/material-web/expansion/accordion.js';
import {MdExpansionPanel as MdExpansionPanelElement} from '@aphrody/material-web/expansion/expansion-panel.js';
export const MdAccordion = createComponent({
react: React,
tagName: 'md-accordion',
elementClass: MdAccordionElement,
events: {},
});
export const MdExpansionPanel = createComponent({
react: React,
tagName: 'md-expansion-panel',
elementClass: MdExpansionPanelElement,
events: {onToggle: 'expansion:toggle'},
});
+21
View File
@@ -0,0 +1,21 @@
// AUTO-GENERATED by generate.ts — do not edit by hand. Run `bun run generate`.
import * as React from 'react';
import {createComponent} from '@lit/react';
import {MdBrandedFab as MdBrandedFabElement} from '@aphrody/material-web/fab/branded-fab.js';
import {MdFab as MdFabElement} from '@aphrody/material-web/fab/fab.js';
export const MdBrandedFab = createComponent({
react: React,
tagName: 'md-branded-fab',
elementClass: MdBrandedFabElement,
events: {},
});
export const MdFab = createComponent({
react: React,
tagName: 'md-fab',
elementClass: MdFabElement,
events: {},
});
+21
View File
@@ -0,0 +1,21 @@
// AUTO-GENERATED by generate.ts — do not edit by hand. Run `bun run generate`.
import * as React from 'react';
import {createComponent} from '@lit/react';
import {MdFabMenu as MdFabMenuElement} from '@aphrody/material-web/fabmenu/fab-menu.js';
import {MdFabMenuItem as MdFabMenuItemElement} from '@aphrody/material-web/fabmenu/fab-menu-item.js';
export const MdFabMenu = createComponent({
react: React,
tagName: 'md-fab-menu',
elementClass: MdFabMenuElement,
events: {onOpen: 'fab-menu:open', onClose: 'fab-menu:close'},
});
export const MdFabMenuItem = createComponent({
react: React,
tagName: 'md-fab-menu-item',
elementClass: MdFabMenuItemElement,
events: {onClick: 'fab-menu-item:click'},
});
+21
View File
@@ -0,0 +1,21 @@
// AUTO-GENERATED by generate.ts — do not edit by hand. Run `bun run generate`.
import * as React from 'react';
import {createComponent} from '@lit/react';
import {MdFilledField as MdFilledFieldElement} from '@aphrody/material-web/field/filled-field.js';
import {MdOutlinedField as MdOutlinedFieldElement} from '@aphrody/material-web/field/outlined-field.js';
export const MdFilledField = createComponent({
react: React,
tagName: 'md-filled-field',
elementClass: MdFilledFieldElement,
events: {},
});
export const MdOutlinedField = createComponent({
react: React,
tagName: 'md-outlined-field',
elementClass: MdOutlinedFieldElement,
events: {},
});
+13
View File
@@ -0,0 +1,13 @@
// AUTO-GENERATED by generate.ts — do not edit by hand. Run `bun run generate`.
import * as React from 'react';
import {createComponent} from '@lit/react';
import {MdFocusRing as MdFocusRingElement} from '@aphrody/material-web/focus/md-focus-ring.js';
export const MdFocusRing = createComponent({
react: React,
tagName: 'md-focus-ring',
elementClass: MdFocusRingElement,
events: {},
});
+13
View File
@@ -0,0 +1,13 @@
// AUTO-GENERATED by generate.ts — do not edit by hand. Run `bun run generate`.
import * as React from 'react';
import {createComponent} from '@lit/react';
import {M3Globe3D as Md3dGlobeElement} from '@aphrody/material-web/globe3d/globe-3d.js';
export const Md3dGlobe = createComponent({
react: React,
tagName: 'md-3d-globe',
elementClass: Md3dGlobeElement,
events: {},
});
+21
View File
@@ -0,0 +1,21 @@
// AUTO-GENERATED by generate.ts — do not edit by hand. Run `bun run generate`.
import * as React from 'react';
import {createComponent} from '@lit/react';
import {MdGridList as MdGridListElement} from '@aphrody/material-web/gridlist/grid-list.js';
import {MdGridTile as MdGridTileElement} from '@aphrody/material-web/gridlist/grid-tile.js';
export const MdGridList = createComponent({
react: React,
tagName: 'md-grid-list',
elementClass: MdGridListElement,
events: {},
});
export const MdGridTile = createComponent({
react: React,
tagName: 'md-grid-tile',
elementClass: MdGridTileElement,
events: {},
});
+13
View File
@@ -0,0 +1,13 @@
// AUTO-GENERATED by generate.ts — do not edit by hand. Run `bun run generate`.
import * as React from 'react';
import {createComponent} from '@lit/react';
import {MdIcon as MdIconElement} from '@aphrody/material-web/icon/icon.js';
export const MdIcon = createComponent({
react: React,
tagName: 'md-icon',
elementClass: MdIconElement,
events: {},
});
+37
View File
@@ -0,0 +1,37 @@
// AUTO-GENERATED by generate.ts — do not edit by hand. Run `bun run generate`.
import * as React from 'react';
import {createComponent} from '@lit/react';
import {MdFilledIconButton as MdFilledIconButtonElement} from '@aphrody/material-web/iconbutton/filled-icon-button.js';
import {MdFilledTonalIconButton as MdFilledTonalIconButtonElement} from '@aphrody/material-web/iconbutton/filled-tonal-icon-button.js';
import {MdIconButton as MdIconButtonElement} from '@aphrody/material-web/iconbutton/icon-button.js';
import {MdOutlinedIconButton as MdOutlinedIconButtonElement} from '@aphrody/material-web/iconbutton/outlined-icon-button.js';
export const MdFilledIconButton = createComponent({
react: React,
tagName: 'md-filled-icon-button',
elementClass: MdFilledIconButtonElement,
events: {},
});
export const MdFilledTonalIconButton = createComponent({
react: React,
tagName: 'md-filled-tonal-icon-button',
elementClass: MdFilledTonalIconButtonElement,
events: {},
});
export const MdIconButton = createComponent({
react: React,
tagName: 'md-icon-button',
elementClass: MdIconButtonElement,
events: {},
});
export const MdOutlinedIconButton = createComponent({
react: React,
tagName: 'md-outlined-icon-button',
elementClass: MdOutlinedIconButtonElement,
events: {},
});
+109
View File
@@ -0,0 +1,109 @@
// AUTO-GENERATED by generate.ts — do not edit by hand. Run `bun run generate`.
import * as React from 'react';
import {createComponent} from '@lit/react';
import {Button as MdButtonElement} from '@aphrody/material-web/labs/gb/components/button/md-button.js';
import {MdElevatedCard as MdElevatedCardElement} from '@aphrody/material-web/labs/card/elevated-card.js';
import {MdFilledCard as MdFilledCardElement} from '@aphrody/material-web/labs/card/filled-card.js';
import {MdItem as MdItemElement} from '@aphrody/material-web/labs/item/item.js';
import {MenuGroup as MdMenuGroupElement} from '@aphrody/material-web/labs/gb/components/menu/md-menu-group.js';
import {MdNavigationBar as MdNavigationBarElement} from '@aphrody/material-web/labs/navigationbar/navigation-bar.js';
import {MdNavigationDrawer as MdNavigationDrawerElement} from '@aphrody/material-web/labs/navigationdrawer/navigation-drawer.js';
import {MdNavigationDrawerModal as MdNavigationDrawerModalElement} from '@aphrody/material-web/labs/navigationdrawer/navigation-drawer-modal.js';
import {MdNavigationTab as MdNavigationTabElement} from '@aphrody/material-web/labs/navigationtab/navigation-tab.js';
import {MdOutlinedCard as MdOutlinedCardElement} from '@aphrody/material-web/labs/card/outlined-card.js';
import {MdOutlinedSegmentedButton as MdOutlinedSegmentedButtonElement} from '@aphrody/material-web/labs/segmentedbutton/outlined-segmented-button.js';
import {MdOutlinedSegmentedButtonSet as MdOutlinedSegmentedButtonSetElement} from '@aphrody/material-web/labs/segmentedbuttonset/outlined-segmented-button-set.js';
import {SplitButton as MdSplitButtonElement} from '@aphrody/material-web/labs/gb/components/splitbutton/md-split-button.js';
export const MdButton = createComponent({
react: React,
tagName: 'md-button',
elementClass: MdButtonElement,
events: {},
});
export const MdElevatedCard = createComponent({
react: React,
tagName: 'md-elevated-card',
elementClass: MdElevatedCardElement,
events: {},
});
export const MdFilledCard = createComponent({
react: React,
tagName: 'md-filled-card',
elementClass: MdFilledCardElement,
events: {},
});
export const MdItem = createComponent({
react: React,
tagName: 'md-item',
elementClass: MdItemElement,
events: {},
});
export const MdMenuGroup = createComponent({
react: React,
tagName: 'md-menu-group',
elementClass: MdMenuGroupElement,
events: {},
});
export const MdNavigationBar = createComponent({
react: React,
tagName: 'md-navigation-bar',
elementClass: MdNavigationBarElement,
events: {onActivated: 'navigation-bar-activated'},
});
export const MdNavigationDrawer = createComponent({
react: React,
tagName: 'md-navigation-drawer',
elementClass: MdNavigationDrawerElement,
events: {onNavigationDrawerChanged: 'navigation-drawer-changed'},
});
export const MdNavigationDrawerModal = createComponent({
react: React,
tagName: 'md-navigation-drawer-modal',
elementClass: MdNavigationDrawerModalElement,
events: {onNavigationDrawerChanged: 'navigation-drawer-changed'},
});
export const MdNavigationTab = createComponent({
react: React,
tagName: 'md-navigation-tab',
elementClass: MdNavigationTabElement,
events: {},
});
export const MdOutlinedCard = createComponent({
react: React,
tagName: 'md-outlined-card',
elementClass: MdOutlinedCardElement,
events: {},
});
export const MdOutlinedSegmentedButton = createComponent({
react: React,
tagName: 'md-outlined-segmented-button',
elementClass: MdOutlinedSegmentedButtonElement,
events: {},
});
export const MdOutlinedSegmentedButtonSet = createComponent({
react: React,
tagName: 'md-outlined-segmented-button-set',
elementClass: MdOutlinedSegmentedButtonSetElement,
events: {},
});
export const MdSplitButton = createComponent({
react: React,
tagName: 'md-split-button',
elementClass: MdSplitButtonElement,
events: {},
});
+37
View File
@@ -0,0 +1,37 @@
// AUTO-GENERATED by generate.ts — do not edit by hand. Run `bun run generate`.
import * as React from 'react';
import {createComponent} from '@lit/react';
import {MdListDetail as MdListDetailElement} from '@aphrody/material-web/layout/md-list-detail.js';
import {MdPane as MdPaneElement} from '@aphrody/material-web/layout/md-pane.js';
import {MdScaffold as MdScaffoldElement} from '@aphrody/material-web/layout/md-scaffold.js';
import {MdSupportingPane as MdSupportingPaneElement} from '@aphrody/material-web/layout/md-supporting-pane.js';
export const MdListDetail = createComponent({
react: React,
tagName: 'md-list-detail',
elementClass: MdListDetailElement,
events: {},
});
export const MdPane = createComponent({
react: React,
tagName: 'md-pane',
elementClass: MdPaneElement,
events: {},
});
export const MdScaffold = createComponent({
react: React,
tagName: 'md-scaffold',
elementClass: MdScaffoldElement,
events: {},
});
export const MdSupportingPane = createComponent({
react: React,
tagName: 'md-supporting-pane',
elementClass: MdSupportingPaneElement,
events: {},
});
+13
View File
@@ -0,0 +1,13 @@
// AUTO-GENERATED by generate.ts — do not edit by hand. Run `bun run generate`.
import * as React from 'react';
import {createComponent} from '@lit/react';
import {MdLink as MdLinkElement} from '@aphrody/material-web/link/link.js';
export const MdLink = createComponent({
react: React,
tagName: 'md-link',
elementClass: MdLinkElement,
events: {},
});
+21
View File
@@ -0,0 +1,21 @@
// AUTO-GENERATED by generate.ts — do not edit by hand. Run `bun run generate`.
import * as React from 'react';
import {createComponent} from '@lit/react';
import {MdList as MdListElement} from '@aphrody/material-web/list/list.js';
import {MdListItem as MdListItemElement} from '@aphrody/material-web/list/list-item.js';
export const MdList = createComponent({
react: React,
tagName: 'md-list',
elementClass: MdListElement,
events: {},
});
export const MdListItem = createComponent({
react: React,
tagName: 'md-list-item',
elementClass: MdListItemElement,
events: {},
});
+13
View File
@@ -0,0 +1,13 @@
// AUTO-GENERATED by generate.ts — do not edit by hand. Run `bun run generate`.
import * as React from 'react';
import {createComponent} from '@lit/react';
import {MdLoadingIndicator as MdLoadingIndicatorElement} from '@aphrody/material-web/loadingindicator/loading-indicator.js';
export const MdLoadingIndicator = createComponent({
react: React,
tagName: 'md-loading-indicator',
elementClass: MdLoadingIndicatorElement,
events: {},
});
+29
View File
@@ -0,0 +1,29 @@
// AUTO-GENERATED by generate.ts — do not edit by hand. Run `bun run generate`.
import * as React from 'react';
import {createComponent} from '@lit/react';
import {MdMenu as MdMenuElement} from '@aphrody/material-web/menu/menu.js';
import {MdMenuItem as MdMenuItemElement} from '@aphrody/material-web/menu/menu-item.js';
import {MdSubMenu as MdSubMenuElement} from '@aphrody/material-web/menu/sub-menu.js';
export const MdMenu = createComponent({
react: React,
tagName: 'md-menu',
elementClass: MdMenuElement,
events: {onOpening: 'opening', onOpened: 'opened', onClosing: 'closing', onClosed: 'closed'},
});
export const MdMenuItem = createComponent({
react: React,
tagName: 'md-menu-item',
elementClass: MdMenuItemElement,
events: {onCloseMenu: 'close-menu'},
});
export const MdSubMenu = createComponent({
react: React,
tagName: 'md-sub-menu',
elementClass: MdSubMenuElement,
events: {onOpening: 'opening', onOpened: 'opened', onClosing: 'closing', onClosed: 'closed'},
});
+13
View File
@@ -0,0 +1,13 @@
// AUTO-GENERATED by generate.ts — do not edit by hand. Run `bun run generate`.
import * as React from 'react';
import {createComponent} from '@lit/react';
import {MdMobileStepper as MdMobileStepperElement} from '@aphrody/material-web/mobilestepper/mobile-stepper.js';
export const MdMobileStepper = createComponent({
react: React,
tagName: 'md-mobile-stepper',
elementClass: MdMobileStepperElement,
events: {},
});
+21
View File
@@ -0,0 +1,21 @@
// AUTO-GENERATED by generate.ts — do not edit by hand. Run `bun run generate`.
import * as React from 'react';
import {createComponent} from '@lit/react';
import {MdNavigationRail as MdNavigationRailElement} from '@aphrody/material-web/navigationrail/navigation-rail.js';
import {MdNavigationRailItem as MdNavigationRailItemElement} from '@aphrody/material-web/navigationrail/navigation-rail-item.js';
export const MdNavigationRail = createComponent({
react: React,
tagName: 'md-navigation-rail',
elementClass: MdNavigationRailElement,
events: {onChange: 'navigation-rail:change', onItemActivate: 'navigation-rail-item:activate'},
});
export const MdNavigationRailItem = createComponent({
react: React,
tagName: 'md-navigation-rail-item',
elementClass: MdNavigationRailItemElement,
events: {},
});
+13
View File
@@ -0,0 +1,13 @@
// AUTO-GENERATED by generate.ts — do not edit by hand. Run `bun run generate`.
import * as React from 'react';
import {createComponent} from '@lit/react';
import {MdPaginator as MdPaginatorElement} from '@aphrody/material-web/paginator/paginator.js';
export const MdPaginator = createComponent({
react: React,
tagName: 'md-paginator',
elementClass: MdPaginatorElement,
events: {onPage: 'paginator:page'},
});
+13
View File
@@ -0,0 +1,13 @@
// AUTO-GENERATED by generate.ts — do not edit by hand. Run `bun run generate`.
import * as React from 'react';
import {createComponent} from '@lit/react';
import {MdPopover as MdPopoverElement} from '@aphrody/material-web/popover/popover.js';
export const MdPopover = createComponent({
react: React,
tagName: 'md-popover',
elementClass: MdPopoverElement,
events: {onOpened: 'opened', onClosed: 'closed'},
});
+21
View File
@@ -0,0 +1,21 @@
// AUTO-GENERATED by generate.ts — do not edit by hand. Run `bun run generate`.
import * as React from 'react';
import {createComponent} from '@lit/react';
import {MdCircularProgress as MdCircularProgressElement} from '@aphrody/material-web/progress/circular-progress.js';
import {MdLinearProgress as MdLinearProgressElement} from '@aphrody/material-web/progress/linear-progress.js';
export const MdCircularProgress = createComponent({
react: React,
tagName: 'md-circular-progress',
elementClass: MdCircularProgressElement,
events: {},
});
export const MdLinearProgress = createComponent({
react: React,
tagName: 'md-linear-progress',
elementClass: MdLinearProgressElement,
events: {},
});
+13
View File
@@ -0,0 +1,13 @@
// AUTO-GENERATED by generate.ts — do not edit by hand. Run `bun run generate`.
import * as React from 'react';
import {createComponent} from '@lit/react';
import {MdRadio as MdRadioElement} from '@aphrody/material-web/radio/radio.js';
export const MdRadio = createComponent({
react: React,
tagName: 'md-radio',
elementClass: MdRadioElement,
events: {onChange: 'change', onInput: 'input'},
});
+13
View File
@@ -0,0 +1,13 @@
// AUTO-GENERATED by generate.ts — do not edit by hand. Run `bun run generate`.
import * as React from 'react';
import {createComponent} from '@lit/react';
import {MdRating as MdRatingElement} from '@aphrody/material-web/rating/rating.js';
export const MdRating = createComponent({
react: React,
tagName: 'md-rating',
elementClass: MdRatingElement,
events: {onChange: 'change', onInput: 'input'},
});
+13
View File
@@ -0,0 +1,13 @@
// AUTO-GENERATED by generate.ts — do not edit by hand. Run `bun run generate`.
import * as React from 'react';
import {createComponent} from '@lit/react';
import {MdRipple as MdRippleElement} from '@aphrody/material-web/ripple/ripple.js';
export const MdRipple = createComponent({
react: React,
tagName: 'md-ripple',
elementClass: MdRippleElement,
events: {},
});
+13
View File
@@ -0,0 +1,13 @@
// AUTO-GENERATED by generate.ts — do not edit by hand. Run `bun run generate`.
import * as React from 'react';
import {createComponent} from '@lit/react';
import {MdScheduler as MdSchedulerElement} from '@aphrody/material-web/scheduler/scheduler.js';
export const MdScheduler = createComponent({
react: React,
tagName: 'md-scheduler',
elementClass: MdSchedulerElement,
events: {onNavigate: 'scheduler:navigate', onSelect: 'scheduler:select', onEventClick: 'scheduler:event-click'},
});
+13
View File
@@ -0,0 +1,13 @@
// AUTO-GENERATED by generate.ts — do not edit by hand. Run `bun run generate`.
import * as React from 'react';
import {createComponent} from '@lit/react';
import {MdSearchBar as MdSearchBarElement} from '@aphrody/material-web/search/search-bar.js';
export const MdSearchBar = createComponent({
react: React,
tagName: 'md-search-bar',
elementClass: MdSearchBarElement,
events: {onInput: 'input', onSearch: 'search', onSearchOpen: 'search:open', onSearchClose: 'search:close'},
});
+29
View File
@@ -0,0 +1,29 @@
// AUTO-GENERATED by generate.ts — do not edit by hand. Run `bun run generate`.
import * as React from 'react';
import {createComponent} from '@lit/react';
import {MdFilledSelect as MdFilledSelectElement} from '@aphrody/material-web/select/filled-select.js';
import {MdOutlinedSelect as MdOutlinedSelectElement} from '@aphrody/material-web/select/outlined-select.js';
import {MdSelectOption as MdSelectOptionElement} from '@aphrody/material-web/select/select-option.js';
export const MdFilledSelect = createComponent({
react: React,
tagName: 'md-filled-select',
elementClass: MdFilledSelectElement,
events: {onChange: 'change', onInput: 'input', onOpening: 'opening', onOpened: 'opened', onClosing: 'closing', onClosed: 'closed'},
});
export const MdOutlinedSelect = createComponent({
react: React,
tagName: 'md-outlined-select',
elementClass: MdOutlinedSelectElement,
events: {onChange: 'change', onInput: 'input', onOpening: 'opening', onOpened: 'opened', onClosing: 'closing', onClosed: 'closed'},
});
export const MdSelectOption = createComponent({
react: React,
tagName: 'md-select-option',
elementClass: MdSelectOptionElement,
events: {},
});
+21
View File
@@ -0,0 +1,21 @@
// AUTO-GENERATED by generate.ts — do not edit by hand. Run `bun run generate`.
import * as React from 'react';
import {createComponent} from '@lit/react';
import {MdBottomSheet as MdBottomSheetElement} from '@aphrody/material-web/sheet/bottom-sheet.js';
import {MdSideSheet as MdSideSheetElement} from '@aphrody/material-web/sheet/side-sheet.js';
export const MdBottomSheet = createComponent({
react: React,
tagName: 'md-bottom-sheet',
elementClass: MdBottomSheetElement,
events: {onOpening: 'bottom-sheet:opening', onOpened: 'bottom-sheet:opened', onClosing: 'bottom-sheet:closing', onClosed: 'bottom-sheet:closed'},
});
export const MdSideSheet = createComponent({
react: React,
tagName: 'md-side-sheet',
elementClass: MdSideSheetElement,
events: {onOpening: 'side-sheet:opening', onOpened: 'side-sheet:opened', onClosing: 'side-sheet:closing', onClosed: 'side-sheet:closed'},
});
+13
View File
@@ -0,0 +1,13 @@
// AUTO-GENERATED by generate.ts — do not edit by hand. Run `bun run generate`.
import * as React from 'react';
import {createComponent} from '@lit/react';
import {MdSkeleton as MdSkeletonElement} from '@aphrody/material-web/skeleton/skeleton.js';
export const MdSkeleton = createComponent({
react: React,
tagName: 'md-skeleton',
elementClass: MdSkeletonElement,
events: {},
});
+13
View File
@@ -0,0 +1,13 @@
// AUTO-GENERATED by generate.ts — do not edit by hand. Run `bun run generate`.
import * as React from 'react';
import {createComponent} from '@lit/react';
import {MdSlider as MdSliderElement} from '@aphrody/material-web/slider/slider.js';
export const MdSlider = createComponent({
react: React,
tagName: 'md-slider',
elementClass: MdSliderElement,
events: {onChange: 'change', onInput: 'input'},
});
+13
View File
@@ -0,0 +1,13 @@
// AUTO-GENERATED by generate.ts — do not edit by hand. Run `bun run generate`.
import * as React from 'react';
import {createComponent} from '@lit/react';
import {MdSnackbar as MdSnackbarElement} from '@aphrody/material-web/snackbar/snackbar.js';
export const MdSnackbar = createComponent({
react: React,
tagName: 'md-snackbar',
elementClass: MdSnackbarElement,
events: {onOpening: 'opening', onOpened: 'opened', onClosing: 'closing', onClosed: 'closed'},
});
+21
View File
@@ -0,0 +1,21 @@
// AUTO-GENERATED by generate.ts — do not edit by hand. Run `bun run generate`.
import * as React from 'react';
import {createComponent} from '@lit/react';
import {MdStep as MdStepElement} from '@aphrody/material-web/stepper/step.js';
import {MdStepper as MdStepperElement} from '@aphrody/material-web/stepper/stepper.js';
export const MdStep = createComponent({
react: React,
tagName: 'md-step',
elementClass: MdStepElement,
events: {},
});
export const MdStepper = createComponent({
react: React,
tagName: 'md-stepper',
elementClass: MdStepperElement,
events: {onChange: 'stepper:change'},
});
+13
View File
@@ -0,0 +1,13 @@
// AUTO-GENERATED by generate.ts — do not edit by hand. Run `bun run generate`.
import * as React from 'react';
import {createComponent} from '@lit/react';
import {MdSurface as MdSurfaceElement} from '@aphrody/material-web/surface/surface.js';
export const MdSurface = createComponent({
react: React,
tagName: 'md-surface',
elementClass: MdSurfaceElement,
events: {},
});
+13
View File
@@ -0,0 +1,13 @@
// AUTO-GENERATED by generate.ts — do not edit by hand. Run `bun run generate`.
import * as React from 'react';
import {createComponent} from '@lit/react';
import {MdSwitch as MdSwitchElement} from '@aphrody/material-web/switch/switch.js';
export const MdSwitch = createComponent({
react: React,
tagName: 'md-switch',
elementClass: MdSwitchElement,
events: {onChange: 'change', onInput: 'input'},
});
+13
View File
@@ -0,0 +1,13 @@
// AUTO-GENERATED by generate.ts — do not edit by hand. Run `bun run generate`.
import * as React from 'react';
import {createComponent} from '@lit/react';
import {MdTable as MdTableElement} from '@aphrody/material-web/table/table.js';
export const MdTable = createComponent({
react: React,
tagName: 'md-table',
elementClass: MdTableElement,
events: {onSort: 'table:sort', onSelectionChange: 'table:selection-change'},
});
+29
View File
@@ -0,0 +1,29 @@
// AUTO-GENERATED by generate.ts — do not edit by hand. Run `bun run generate`.
import * as React from 'react';
import {createComponent} from '@lit/react';
import {MdPrimaryTab as MdPrimaryTabElement} from '@aphrody/material-web/tabs/primary-tab.js';
import {MdSecondaryTab as MdSecondaryTabElement} from '@aphrody/material-web/tabs/secondary-tab.js';
import {MdTabs as MdTabsElement} from '@aphrody/material-web/tabs/tabs.js';
export const MdPrimaryTab = createComponent({
react: React,
tagName: 'md-primary-tab',
elementClass: MdPrimaryTabElement,
events: {},
});
export const MdSecondaryTab = createComponent({
react: React,
tagName: 'md-secondary-tab',
elementClass: MdSecondaryTabElement,
events: {},
});
export const MdTabs = createComponent({
react: React,
tagName: 'md-tabs',
elementClass: MdTabsElement,
events: {onChange: 'change'},
});
+21
View File
@@ -0,0 +1,21 @@
// AUTO-GENERATED by generate.ts — do not edit by hand. Run `bun run generate`.
import * as React from 'react';
import {createComponent} from '@lit/react';
import {MdFilledTextField as MdFilledTextFieldElement} from '@aphrody/material-web/textfield/filled-text-field.js';
import {MdOutlinedTextField as MdOutlinedTextFieldElement} from '@aphrody/material-web/textfield/outlined-text-field.js';
export const MdFilledTextField = createComponent({
react: React,
tagName: 'md-filled-text-field',
elementClass: MdFilledTextFieldElement,
events: {onChange: 'change', onInput: 'input'},
});
export const MdOutlinedTextField = createComponent({
react: React,
tagName: 'md-outlined-text-field',
elementClass: MdOutlinedTextFieldElement,
events: {onChange: 'change', onInput: 'input'},
});
+13
View File
@@ -0,0 +1,13 @@
// AUTO-GENERATED by generate.ts — do not edit by hand. Run `bun run generate`.
import * as React from 'react';
import {createComponent} from '@lit/react';
import {MdTimePicker as MdTimePickerElement} from '@aphrody/material-web/timepicker/time-picker.js';
export const MdTimePicker = createComponent({
react: React,
tagName: 'md-time-picker',
elementClass: MdTimePickerElement,
events: {onChange: 'time-picker:change'},
});
+13
View File
@@ -0,0 +1,13 @@
// AUTO-GENERATED by generate.ts — do not edit by hand. Run `bun run generate`.
import * as React from 'react';
import {createComponent} from '@lit/react';
import {MdToolbar as MdToolbarElement} from '@aphrody/material-web/toolbar/md-toolbar.js';
export const MdToolbar = createComponent({
react: React,
tagName: 'md-toolbar',
elementClass: MdToolbarElement,
events: {},
});
+13
View File
@@ -0,0 +1,13 @@
// AUTO-GENERATED by generate.ts — do not edit by hand. Run `bun run generate`.
import * as React from 'react';
import {createComponent} from '@lit/react';
import {MdTooltip as MdTooltipElement} from '@aphrody/material-web/tooltip/tooltip.js';
export const MdTooltip = createComponent({
react: React,
tagName: 'md-tooltip',
elementClass: MdTooltipElement,
events: {onShow: 'tooltip-show', onHide: 'tooltip-hide'},
});
+21
View File
@@ -0,0 +1,21 @@
// AUTO-GENERATED by generate.ts — do not edit by hand. Run `bun run generate`.
import * as React from 'react';
import {createComponent} from '@lit/react';
import {MdTree as MdTreeElement} from '@aphrody/material-web/tree/tree.js';
import {MdTreeItem as MdTreeItemElement} from '@aphrody/material-web/tree/tree-item.js';
export const MdTree = createComponent({
react: React,
tagName: 'md-tree',
elementClass: MdTreeElement,
events: {onSelect: 'tree:select'},
});
export const MdTreeItem = createComponent({
react: React,
tagName: 'md-tree-item',
elementClass: MdTreeItemElement,
events: {onToggle: 'tree-item:toggle', onActivate: 'tree-item:activate'},
});
+13
View File
@@ -0,0 +1,13 @@
// AUTO-GENERATED by generate.ts — do not edit by hand. Run `bun run generate`.
import * as React from 'react';
import {createComponent} from '@lit/react';
import {MdType as MdTypeElement} from '@aphrody/material-web/typography/md-type.js';
export const MdType = createComponent({
react: React,
tagName: 'md-type',
elementClass: MdTypeElement,
events: {},
});
+13
View File
@@ -0,0 +1,13 @@
// AUTO-GENERATED by generate.ts — do not edit by hand. Run `bun run generate`.
import * as React from 'react';
import {createComponent} from '@lit/react';
import {MdVirtualScroller as MdVirtualScrollerElement} from '@aphrody/material-web/virtualscroll/virtual-scroller.js';
export const MdVirtualScroller = createComponent({
react: React,
tagName: 'md-virtual-scroller',
elementClass: MdVirtualScrollerElement,
events: {onRange: 'virtual-scroll:range'},
});