Skip to content

Releases: jooy2/material-plus-ui

Material Plus 1.11.0

Choose a tag to compare

@github-actions github-actions released this 07 Oct 05:44

Most of this release closes the distance between what a component was said to do and what it did. A required picker now tells a screen reader it is required, holds a form's submit back while nothing is chosen, and reaches MPForm's errors and onSubmit under its name. Props that were accepted and then lost, such as the variant an MPButtonGroup gives its icon buttons and aria-label on MPTextField, now take effect, and descriptions that promised what no component did now describe the components. MPSegmentedButton is the one breaking change: it is MD3's 40px at md, so a set at the default size is smaller than before. A new page, Design › Spec coverage, lists how far each component follows Material Design 3.

Breaking changes

  • MPSegmentedButton is MD3's 40px at md, on a ladder of its own. It took the control ladder a button walks, which put the specification's 40dp at sm and made the default a 56px pill. Its rungs are now 32, 36, 40, 48 and 56px. What sm drew is now md, and what md drew is xl: pass size="xl" to keep a 56px set. There is no 64px or 72px set any more.

Changed

  • color on MPTable and MPTreeSelect is deprecated, because it never did anything. A table's rows are neutral on purpose, since a table that tints its rows colours its data, and a tree select's chosen row is secondary-container, as an MPSelect's chosen option is. Both props still compile, and both are due to be removed in the next major version.

  • An indeterminate MPProgressLinear moves on the library's own motion tokens. Each bar's leading and trailing edge now travel separately, the head on standard and the tail on standard-accelerate, over a cycle of two extra-long4s, so a page that retunes its motion retunes the indicator with it. It is still a long sweep followed by a short one that catches up; the timing is new.

  • MPTreeSelect searches the way MPCombobox does. It folded case and nothing else, while the combobox matches through Base UI's collator, so "malaga" found "Málaga" in one and not in the other. Both now leave case, accents and punctuation out of the comparison, and both compare in the language of locale — the combobox fell back to the browser's before, which need not be the one the field is written in.

Documentation

  • A page on how far the library follows the specification. Design › Spec coverage lists, component by component, what Material Design 3 and M3 Expressive define, which of it is here, what the library adds, and what Material UI offers for the same need.

  • The colour page says twenty-nine roles, not five. It still described the five an outlined text field reads, from before the rest of the library arrived.

  • MPButton and MPFloatingActionButton no longer say MD3 dropped the ripple and the FAB menu. MD3 still draws a ripple on press, and M3 Expressive added a FAB menu of labelled actions in 2025. Neither is drawn here, for the reasons each page gives.

  • Descriptions that disagreed with their components now say what the components do.

    • An MPBadge with no content is hidden, as a count of zero is; the dot is dot. It never drew a dot on its own.
    • MPSnackbarProvider's six stacks share one live region, there is no 344dp minimum on the plate, and the × is 20px at md.
    • An uncontrolled MPStepper moves when a step is pressed, with or without onActiveChange.
    • MPHoverCard opens on keyboard focus too, after the same delay as a pointer.
    • color on MPList and MPCommandPalette does not tint the hover; the state layer is the row's own ink, as everywhere else.
    • MPSidebar is on the bar ladder MPHeader and MPFooter use, not the container one.
    • MPProgressLinear's groove is on-surface at 12%, not the family's container tone, and MPProgressCircular stops on a partial arc under prefers-reduced-motion.
    • MPAvatar's square corners are smaller on the two smallest rungs, and an MPButton's glyphs keep the size they were given.
    • MPTextField's id is not derived from name, and its password toggle is named "Show the password" and "Hide the password".
    • MPTable's 52dp row is the Material 2 data table's, since MD3 has no data table.
    • MPTooltip's props table gives its default size as sm, which is what it is and what the configuration guide already said.

Fixed

  • An unreachable MPStepper step stays in the tab order. It was drawn as a <span>, which no keyboard reaches, carrying an aria-disabled that assistive technology ignores on a plain element — so a reader walking the rail found a gap with nothing to say why. It is now a button marked aria-disabled that refuses the press, as the documentation always said. A step whose own disabled is set is drawn the same way.

  • An open MPCollapsible stops clipping its body. The panel clips while its height moves, and it went on clipping once it had arrived, which cut the top off a text field's floating label at the top of the body. MPAccordion had already been fixed for the same thing, and the two now share the fix.

  • MPAccordion's density takes room from under each body as well. It tightened the header and the sides of the body and left the space under the body where it was.

  • An indeterminate MPProgressBox pulses in the accent. Its segments carried the grey track class and the accent one together, and the grey came later in the stylesheet, so the wave was a 12% wash that was barely there.

  • MPIconButton takes the variant of the MPButtonGroup around it. Its own default of text reached the button as if the caller had chosen it, so the group's never arrived, and a group of outlined buttons drew its icon buttons bare. Outside a group, or in one that names no variant, an icon button is still text.

  • A selected MPChip leads with a tick. filled and tonal already rest on the tones a selected chip takes, so a selected one looked exactly like one that was off. The tick is MD3's selected filter chip's, and on those two variants it is the only change. A chip given a startIcon of its own keeps it.

  • MPFloatingBottomNavigation draws the variant it is given. Its default elevation of 3 applied whatever variant said, and a level brings its own tone, so every variant but outlined drew the same sheet. The default level now applies only while variant is unset; set elevation as well to lift a chosen surface.

  • A required picker says so to a screen reader. MPDatePicker, MPDateRangePicker, MPDateTimePicker, MPTimePicker, MPTreeSelect and MPColorPicker open from a <button>, which takes neither required nor aria-required, and the asterisk is hidden from assistive technology, so nothing said it. The label now carries the word in the reader's language. It is common.required in a table passed to registerMPMessages. An MPColorPicker drawn inline says it in its label as well.

  • MPForm's errors reach the pickers and MPColorPicker. Base UI matches an error to a field by the name its control registers, and a popover trigger registers none, so an error keyed by a picker's name found nothing to show on. The name is now given to the field itself.

  • A required picker holds a form's submit back while nothing is chosen. required only drew an asterisk, because the trigger is a button and a button has no value for a form to check. Each picker and MPColorPicker now carries an input that stands in for its value: MPForm refuses the submit, shows the browser's message under the field and moves focus to the trigger, and an ordinary <form> refuses it too. A read-only picker is not checked, as a read-only input is not.

  • MPForm's onSubmit receives the pickers' values. It only sees the fields Base UI registers, and a popover trigger registers none, so a picker's value reached an action but never onSubmit. Each now comes under its name as one string: a range as an ISO 8601 interval such as 2026-07-01/2026-07-04, and an MPTreeSelect with multiple as its values joined by commas.

  • MPDataTable's sort arrow is drawn in the family color names. It was primary whatever color said, while the ticks and the pagination beside it followed. The documentation also said a chosen row read the family; it is secondary-container, as a chosen option is everywhere in the library, and the documentation now says so.

  • MPTextField takes aria-label. A hyphenated attribute is never type-checked on a component, so it was accepted and dropped without a word. MPTransfer's filter fields were named only by their placeholder, which disappears once there is a query, and now carry a name of their own.


Every release of this package is in the changelog on the docs site and in CHANGELOG.md at this tag.

Material Plus 1.10.0

Choose a tag to compare

@github-actions github-actions released this 04 Oct 09:11

This release is about what the library costs the site it runs on. A server-rendered page hydrates without the mismatches that made React throw its markup away and render it again, and its header, panes, charts, mockups and priority image are drawn from the first paint where they stay. Tables, trees, filtered lists and charts re-render only what a change touches, a page of 1,430 elements styles in 21ms in WebKit where it took 164ms, and a handful of opt-in props keep links, headings and pictures in the markup a crawler reads.

Added

  • MPLocaleProvider takes serverLocale, for a server-rendered page that cannot tell its reader's language on the server. A date or a number formatted with no locale is written in the runtime's own, and a server and a browser are two runtimes: the markup said 1,234.5, the first client render said 1.234,5, and React discarded the server's markup and rendered the page again. serverLocale is the language the server pass and the hydrating pass are both written in; the dates and numbers are rewritten in the reader's language once the page has hydrated. A server that formats with neither a locale nor a serverLocale now says so in its log, once. See On a server.

  • MPNavigationMenu takes keepMounted, for the links a crawler should find. A closed panel is not in the page at all, so links that exist only inside one were in neither the HTML a server sent nor the document a crawler rendered. With keepMounted every panel's links are in the server's markup, hidden, and stay in the document after a panel has opened and closed.

  • MPDrawer and MPSidebar take keepMounted. A closed modal drawer is not in the page, so below collapseBelow a sidebar's links left the document a crawler renders at a phone's width. With keepMounted a closed drawer's contents stay in the document, hidden.

  • MPAvatar takes keepMounted, for a long list of them. By default a picture is fetched by a script after the page has hydrated, so it is in no server's markup, the preload scanner never sees it and loading="lazy" is ignored — fifty avatars download together the moment the page hydrates, competing with its largest picture. With keepMounted each is an <img> from the start, loaded by the browser on its own terms, with the initials under it until it has arrived. It needs @base-ui/react 1.8 or later.

  • A router's Link can draw the links in MPBreadcrumbItem, MPNavigationMenuItem, MPNavigationMenuLink, MPMenuItem and MPTreeItem, through render, and MPPagination's, through renderLink. Each drew a plain <a>, and under App Router a plain <a> to a page of the same site is a full page load, with no client-side navigation and no prefetch. render={<Link />} keeps the href on the component, written once, and lets the router draw the element — the arrangement MPListItem already had.

  • MPAccordion takes headingLevel. Each section's title is a heading, and it was always an <h3> — so an accordion under an <h3> of its own, or directly under the page's <h1>, broke the outline a screen reader navigates by and a search engine reads a page's structure from. The default is still 3.

  • MPAnimateHeadline, MPAnimateTyping, MPAnimateScramble and MPAnimateSplit take render, as MPAnimateFade already did. Their root is a <div>, and the natural place for a rotating word or a typed phrase is inside a paragraph — where an HTML parser closes the <p> at the <div>, so the server's markup and React's tree disagree and hydration fails. render={<span />} makes the root inline.

  • MPConfigProvider takes reserveSupportingText. A field opens a line under itself when it has a description or an error, and everything below moves down. An error that arrives well after the reader typed — a server's answer, a debounced check, MPForm's errors — is a layout shift with nothing the reader did to explain it. With reserveSupportingText every field in the subtree keeps that line from the start, and a message fills it in place.

  • --mp-scrim-blur sets how much the scrim under a dialog, a drawer or the command palette blurs the page. The 2px blur re-blurs the whole window on every frame the scrim fades and every frame anything under it changes, which is a cost a low-end phone feels. 0px keeps the tint and drops the filter; the default is still 2px.

Changed

  • The stylesheet costs the page around it far less to style. The colour roles are declared on every element so that a scoped --mp-source-color or .dark can reach them, and that rule also named ::before and ::after — which made a browser resolve both pseudo-elements of every element on the page, this library's or not, to find out whether either drew anything. Measured on a plain page of 1,430 elements, the first style pass took 164ms in WebKit and now takes 21ms, and 68ms in Firefox and now 26ms. A pseudo-element inherits the roles its element resolved, so every colour is drawn exactly as before, scoped themes included.

  • A media query is asked once per page, not once per render. The reduced-motion, colour-scheme and collapse stores built a fresh MediaQueryList every time React read them — on every render of every component subscribed, and again to check nothing had changed — so twelve animated lines built seventy-two lists on mount. Each query now has one list, shared. MPShortcut likewise reads the platform once rather than rebuilding the user agent string on every render.

  • Everything that watches whether it is on screen shares one observer. Every MPAnimate* with trigger="visible", every useMPOnScreen and every autoplaying MPCarousel created an IntersectionObserver of its own, and the browser checks each one on every frame the page scrolls — a page of forty fading cards was forty observers asking one question. Elements that ask the same question, at the same threshold and margin, are now watched by one, and each is told about itself exactly as before.

  • An endless animation holds while it is off screen. MPAnimateLighting's glow repainted its gradient every frame and an indeterminate MPProgressLinear moved its bars by layout every frame, for as long as the page was open and wherever on it they were. Both, and an indeterminate MPProgressCircular, now pause while scrolled out of view and carry on from where they stopped when they come back.

  • An MPImage given priority is drawn the moment it arrives, rather than faded in. The fade waits for the picture's load event, and on a server-rendered page that event is only heard once the page's JavaScript has hydrated — so the one picture a page is judged by sat transparent over bytes the browser already had, and a transparent picture does not count as its Largest Contentful Paint. With the script held back 1.5 seconds it painted at 1.9 seconds, where a bare <img> painted at 0.3. Its placeholder is now drawn under it instead of over it. A picture without priority fades in as before.

  • A fixed MPHeader in an MPPageLayout has its room from the first paint. The layout pads the page by the bar's measured height, and measuring is an effect — so a server-rendered page drew its content under the bar and pushed it down by the bar's height once it had hydrated. The stylesheet now pads by the bar's size from the start, and the measurement corrects only a bar its content made taller. It needs :has(); an older browser behaves as before. MPHeader carries data-mp-position for it.

  • MPPanes paints at its defaultSize from the first frame. Until the split had measured itself every pane took an even share, so a server-rendered page drew a 50/50 split and then moved every boundary to 25% or 240px once it hydrated — a layout shift across the whole of an app shell, measured at 0.038 for a two-pane split. A defaultSize is now written into the pane's CSS until the measurement arrives, and the measurement lands where the CSS already was.

  • MPGaugeChart's reading and the content in an MPPieChart ring start where they stay. Both are placed from the size of the chart's box, which a server does not know, so a server-rendered page drew the gauge's value against the top edge and a ring's center in a zero-wide box at the left, and moved both into place once the page had hydrated. Until the box is measured each is now placed as if the dial or the ring fills the box's height, which is exactly where it lands in any box at least as wide as it is tall.

  • An MPMockup given a width or a height in pixels is in the first paint. The device is hidden until it has a scale, and the scale was always measured — so on a server-rendered page a hero mockup, screenshot and all, stayed hidden until the page had hydrated. A size in pixels settles the scale by arithmetic, so the device is drawn from the server's markup on. A mockup that fills a fluid width is still measured, now before its first paint when it mounts in the browser.

  • A chart draws its picture in the frame it mounts in. It measured its box in an effect, which runs after the browser has painted, so a chart mounted in the browser showed an empty box for a frame first — and a server-rendered one stayed empty until the frame after it hydrated. It measures before the paint now.

  • A chart under a moving pointer renders only when what is under the pointer changes. A scatter and a timeline stored the pointer's position on every move although neither ever reads it, a heatmap stored a fresh cell for every pixel inside one cell, and a zoom drag stored the selection again for every pixel inside one category — and each of those is a render of the whole chart. Nineteen moves over one point cost eighteen renders and now cost at most one. The table behind the picture and the legend no longer render at all on a hover, which was half of ...

Read more

Material Plus 1.9.0

Choose a tag to compare

@github-actions github-actions released this 28 Sep 08:02

material-plus-ui/a2ui is new: A2UI's basic catalog drawn as Material Design 3, and five components of this library's own for the tables and charts an agent reporting numbers has to draw — all behind optional peer dependencies, so a project that renders no agent interfaces pays nothing for them. The rest of the release is the charts: an axis that turns a long label instead of cutting it, a line that can bridge a gap or read it as zero, a range a reader can drag out of a long axis, percent stacks, target lines and bands, and a hover panel that sorts its rows and adds up a stack.

Added

  • A chart axis turns a long label instead of cutting it. Whichever axis runs along the bottom now reads tickLabels, and its default auto keeps the labels level while the widest of them fits its slot and turns them once it does not — 45° first, upright where even a turned label's line box will not fit between two ticks. truncate is the old behaviour, and rotate and vertical turn every label whether or not it needed it. MPHeatmapChart's column names read the same option.

    It replaces the two things the axis used to do with a long name, both of which threw the name away: cutting it to "Onbo…", and showing every nth until the survivors fit. A turned label collides across its line box rather than across its own width, and a line box does not grow with the label — so twelve page names fit an axis that was showing four of them. Short labels are left alone however crowded the axis is, because an axis of "Jan", "Feb", "Mar" is crowded by how many of them there are and not by how long any one is — and so are ticks as opposed to names, so auto turns nothing along the bottom of a chart turned on its side or of a scatter, where that axis is a ruler and every nth loses nothing.

    The band a turned axis takes is capped at half the chart's height, and 120px whichever is smaller; past that the names are cut again, and a chart that genuinely needs more wants a taller height or a thickness of its own. Each label is measured against its own tick rather than against the first one, so only the leftmost is ever shortened to stay inside the chart.

  • MPLineChart and MPAreaChart take gaps, for the two readings a broken line gets wrong. connect joins the two sides of a null and draws the joining run dashed, so the line carries on and the picture still says which part of it was measured; on an area the fill closes across the gap as well. zero draws the missing reading at zero and widens the value axis to hold it, which is right where a gap means "none of it happened" and wrong where it means "nobody was counting". break, the default, is what the two charts already did. Only the drawing changes in all three: the hover panel and the table behind the chart still report a gap as a gap.

  • zoom lets a reader drag a range out of a long category axis. For the chart with ninety points on it, where the shape of one week is inside a picture of a quarter: a drag across the plot redraws it to what was picked, and the scale re-fits, so the window is a chart of that range rather than a magnified picture of the whole one. The value scale, the hover panel, the spoken readout and the table behind the picture all follow it — the table most of all, since it is what a reader who cannot see the plot is given instead of it.

    The way back is a Show all control over the corner of the plot, named in all nineteen locales and a sibling of the picture rather than a child: role="img" is a leaf role, so a button inside it is one no keyboard can reach. Escape clears the reading first and the window second, which is the order the reader made them in, and a drag narrower than two categories is ignored — a window of one has no distance left in it for a line to have travelled, and a plain click on the plot is exactly that.

    Pass an object for range and onRangeChange, defaultRange, or a min wider than two. A range is clamped to the data rather than trusted, because one that outlives the series it was picked from is an empty chart with no way back to the full one. Only a chart whose categories are a row of slots takes it: MPScatterChart has two value axes and no row to cut, and crops with xAxis and yAxis instead.

  • The hover panel takes sort, and adds up a stacked column on its own. tooltip={{ sort: 'value' }} puts the largest row first, which is what a chart of eight series wants: the row a reader is looking for is usually the big one. The default stays the order the series were passed, so a reader who has learned the legend can find a row without reading it, and ties keep their series order either way.

    A stack also gets a row for what the column adds up to, ruled off from the parts and carrying no swatch — the height of that column is the whole point of the shape, and a panel that listed the parts without it left the reader adding them up. An unstacked chart does not get one, because its series are not parts of anything; nor does a percent stack, whose answer is 100% every time. tooltip={{ total }} overrides both. The row is read out with the rest of the panel and is named in every locale the library ships.

  • MPBarChart and MPAreaChart take stacked="percent". Every bar is full and every band reaches the top, so the totals leave the picture and what is compared is the composition — the shape a share-of-traffic chart wants. It answers "what is this made of" and refuses to answer "how much of it is there", which is why it is a third setting rather than a flag on the second: a quarter of 1,200 and a quarter of 12 are drawn identically, so a total a reader needs belongs somewhere else on the page.

    The axis runs 0% to 100%, and the hover panel says both the value and its share, because the share is what the picture shows and the value is what the reader came for. A negative has no share of a whole and is left out of one, the way MPPieChart leaves it out; the table behind the chart still has the number. An area's value labels write each band's own share rather than the running total.

  • A chart axis takes references: the target line, the threshold and the band a reader brought with them. yAxis={{ references: [{ value: 300, label: 'SLO', color: 'error' }] }} draws a dashed line across the plot at 300 with its name at the end of it; a reference with a to as well is a band, both edges drawn and the room between them filled. A value may be a Date on an axis of dates.

    It is deliberately not data: a reference wears the chrome's ink rather than a palette slot, it is dashed so it cannot be mistaken for a gridline, and nothing is inferred. The scale opens to hold it, because a target the plot is cropped above is the one thing a target must never be — pin min or max to stop that. A band is drawn under the marks and a line over them: a fill over a bar is a bar the reader has to look through, and a target behind the series that crossed it is a target nobody can read.

    A reference belongs to the axis whose scale its value is on and is drawn perpendicular to it, so the same one is a horizontal line upright and a vertical one on a chart turned on its side. Only an axis with a scale reads them — the value axis always, and the category axis where it runs on numbers, as MPScatterChart's does. A named reference is read out beside the table, which has no column for it.

  • MPSparkline takes gaps too. The same three readings the line and the area chart take: connect joins the two sides of a missing point with a dashed run and closes an area across it, and zero reads the point as a zero and opens the scale to hold it — which matters more on a mark with no axis, since nothing else would say the floor had moved. A bar has no line to join, so it takes zero and reads connect as break. The sentence the mark is read out as still counts only the readings it has.

  • Charts take labelColor, which writes the text naming a series in that series' colour. It covers the legend's entries and the values written onto the marks. ink stays the default, and the reason is measurable rather than stylistic: the eight palette slots are fitted to 3:1 against the surface, which is the bar a mark has to clear, and small text is held to 4.5:1. Labels drawn on a fill — a pie's shares, a heatmap's cells — keep the ink that reads against what they sit on, and MPHeatmapChart and MPTimelineChart do not take the prop at all.

  • MPPieChart takes hole and gap. hole is how much of the radius a donut or a semi leaves empty, from 0 to 0.9 against the previous fixed 0.62; gap is how much page shows between two slices, in pixels, 0 for slices that touch. The gap is still drawn as a stroke rather than by narrowing each sweep, so every slice keeps the angle its share earned however wide it is set.

  • material-plus-ui/a2ui: A2UI's basic catalog, drawn as Material Design 3. A2UI is a protocol for an agent to describe an interface instead of writing one: the agent sends JSON naming components from a catalog, and a renderer on the client draws them. The new subpath implements the protocol's own basic catalog — all eighteen of Text, Image, Icon, Video, AudioPlayer, Row, Column, List, Card, Tabs, Modal, Divider, Button, TextField, CheckBox, ChoicePicker, Slider and DateTimeInput — out of this library's components, so an agent's surface arrives as MD3 rather than as somebody's default styling.

    mpA2uiCatalog is registered under the basic catalog's own id, which makes it a drop-in: a catalogId is a promise about schemas and says nothing about how they are drawn, and these implementations are built from the …Api objects the protocol publishes rather than from copies of them. So an agent already targeting the basic catalog needs no change, and no schema has to be hosted anywher...

Read more

Material Plus 1.8.0

Choose a tag to compare

@jooy2 jooy2 released this 04 Oct 08:04

MPImage grew the eight props a picture actually needs — a turn, a mirror, a crop anchor, a letterbox, a real placeholder — and the rest of the release is what a pass over every other component turned up: a CSV that a spreadsheet will not run, five accessibility gaps in components that had none, an Intl cache the whole library reads through, and a documentation site whose Korean links now land where they point.

Breaking changes

  • A lone width or height on MPImage sizes the box. The two together are still the file's size and still reserve its proportion. One of them alone used to reach the <img> and change nothing, because the picture always filled its box. Now height alone makes the box that tall and as wide as its container, or as wide as ratio makes it, and width alone makes the box that wide, no wider than its container. Code that passed one dimension as a hint about the file now draws at that size: pass both dimensions, or remove the one.

  • MPDataTable's CSV defuses a cell a spreadsheet would run as a formula. A cell beginning =, +, - or @ is a formula to Excel, Sheets and LibreOffice, and the rows in a data table are usually things other people typed — so =HYPERLINK("https://collect.example/?r="&A1) in a name column was one click from posting the row beside it to somebody else's server, on the reader's own machine and out of this component's reach. Such a cell now goes out with an apostrophe in front, which all three read as "the rest of this is text" and strip on the way in. A number keeps its sign: -5 and +82 2 555 0100 begin with a formula character and are not formulas. A file bound for a parser rather than for a person needs exportEscapeFormulas={false} — a database loader has no apostrophe rule and takes the character as part of the value.

  • An MPImage with preview fills its container, as one without it does. The button preview draws was as wide as the picture inside it, so a file narrower than its container drew at its own width once it could be opened, and at the container's width when it could not. Both are the container's width now. To keep a small picture at its own size, give it a lone width, such as the file's width in pixels: the box is then that wide and no wider than its container, which is what the button used to do.

Added

  • MPDataTable takes exportEscapeFormulas. It turns off the formula escaping described above, for the one case where that escaping is wrong: a file going to a parser rather than to a person. @default true

  • MPAppLogo takes target and rel. Both already reached the <a> an href makes, as part of the element's own prop set, and neither was documented or wired to anything. They are named props now and target="_blank" gets a rel on its own, which is the fix below.

  • fit="scale-down" on MPImage and MPAspectRatio. It draws like contain, but never enlarges a file smaller than its box. MPImageFit and MPAspectFit both take it, and a box with a fixed height, from the change above, is where the choice of fit shows.

  • MPImage turns a picture with rotate, a quarter turn at a time. It takes 0, 90, 180 or 270 degrees clockwise, and any other number is rounded to the nearest quarter. A quarter turn swaps the box's proportion: it is reserved from width and height when both are given, read from the file when it loads otherwise, and left alone when ratio is set. The turn is drawn with the rotate property, so a transform of your own still applies, and preview opens the picture turned.

  • MPImage mirrors a picture with flip. It takes 'horizontal', 'vertical' or 'both', measured on the screen, so horizontal swaps left and right whether or not the picture is turned. The mirror is drawn with the scale property, which leaves transform free, and preview opens the picture mirrored.

  • MPImage takes position: which part of the picture a crop keeps. It takes the centre, a side, a corner or two percentages, and also decides where contain, none and scale-down leave their empty space. The value is read on the picture as shown, so top keeps the top of a picture that is turned or mirrored, and it stays physical on a right-to-left page. A value in any other form reaches object-position unchanged.

  • MPImage fills the room fit leaves with letterbox. 'blur' draws the picture again behind itself, covering the box and blurred, the way a video player fills the sides of a portrait clip. The copy is hidden from assistive technology and the pointer, takes the picture's turn, mirror and position, and is given exactly what the picture loads from, so the file is fetched once. It is drawn only for contain, none and scale-down. Any other string is painted behind the picture as a CSS background.

  • MPImage's placeholder takes a picture. { src, blur } draws a small copy of the file, a data URI or a Blob in place of the shimmer while the file arrives, fitted, placed, turned and mirrored like the picture. blur: true is a 20px radius and a number is a radius in pixels. The stand-in stays opaque until the picture has faded in over it and is then hidden in one step, so the page never shows through a cross-fade, and it is removed if the file fails. A Blob is drawn through an object URL that is revoked when the Blob is replaced or the image unmounts. Like the shimmer, it needs a reserved box.

  • MPImage takes priority, for the picture a page is judged by. It sets loading="eager" and a high fetch priority, and a loading or fetchPriority written out on the image still wins. The attribute is spelled the way the running React knows it, fetchPriority in 19 and fetchpriority in 18, so neither warns. The documentation now describes loading, decoding and fetchPriority, which already passed through to the <img>.

  • MPGridItem takes span="auto": exactly the width of what is inside it. MPGridSpan had 'grow' for one end of the question a column count cannot answer — the width the row has left — and nothing for the other end, an item that is the size of its own contents. The two pair, and the layout that wants both is a bar with a group at each end: transport controls at 'grow', and at 'auto' however many icon buttons the page turned out to need. That count is a runtime question, so a share of twelve columns is either too small or too large — four 32px buttons need 128px, large: 2 of a 768px bar is exactly 128px, and the fraction of a pixel rounding takes off wrapped the last button onto a second row and doubled the height of the bar.

    It is responsive like every other span, and a number at a wider class writes the columns back over it. An 'auto' item does not shrink, which nothing in this grid does, so contents wider than the row overflow rather than squeezing what is beside them. The keyword is one no calc() can produce, so it arrives as a whole width on a rung of its own rather than as a number inside the column arithmetic — thirty-five slots reset at a grid boundary now rather than thirty.

Changed

  • MPCarousel's arrows sit at 85% rather than solid. A slide is usually a photograph, and two full-strength discs of the accent on one is chrome shouting over the thing it is there to move. The alpha is on the container and not on the button: opacity would take the glyph down with it, and the glyph against its own disc is the contrast that has to survive. Eighty-five is the lowest step that still clears 4.5:1 over a white slide against the default source — 4.73 there, against 5.04 at 88% and 4.26 at 80%. Hover, focus and a press take it back to solid, and a disabled arrow keeps the specification's own treatment. MD3 names no carousel arrow at all, so this is the library's decision rather than a token being followed.

  • The dependencies are current, and the test runner is on vitest 5. @base-ui/react moves to 1.8, lucide-react to 1.47, React and its types to 19.3, and the rest of the toolchain to its newest. eslint and typescript are deliberately held where they were.

    lucide-react 1.47 is the one update a consumer can measure: it costs about 0.8 kB gzipped on any bundle that draws a glyph, which puts MPButton at 3.8 kB where it was 3.0 and the whole library at 130.6 where it was 129.7. A bundle with no glyph in it — MPBox — is unchanged. Nothing in the library's own code changed to meet any of these.

  • Every Intl formatter the library builds is now cached, keyed on what it was asked for. Constructing one resolves a locale and loads the data behind the options; the object that comes back then formats for free, so a formatter built per value is the one cost worth not paying. The date module had worked this out and cached its own; the charts, MPStatistic, MPSparkline, MPPieChart, the time axis and the text segmenter behind MPAnimateSplit had not — a pie built a percent formatter per slice and a split built a segmenter per word. MPAnimateCounter was the worst of them: it memoised on an options prop, so a caller writing that object inline rebuilt the formatter on every frame of the count. The cache reads what the options say rather than which object said it, so an inline literal hits. localeWeekStart and isHour12, which the four pickers call in their render body, are cached on the locale as well.

  • The package's links point at the repository that exists. repository, bugs and the README's badges all named jooy2/material-plus; the repository is jooy2/material-plus-ui and has been answering for the old name on a redirect. npm renders repository as the source link on the package page and bugs is where npm bugs sends a reader, so both were one cleared redirect away from being dead rather than merely indirect. Twelve links across nine files, including the documentation site's own GitHub and edit links, which...

Read more

Material Plus 1.7.0

Choose a tag to compare

@jooy2 jooy2 released this 04 Oct 08:04

A second report from the application that filed the first one, a day after upgrading to 1.6.0 — and then the largest addition in the library's history, which the report is only the first section of.

Ten of the fifty additions are charts, and they are the release. Nothing here drew data before: an application that wanted a line over eight months reached for a charting library and got a second design system with it — its own colours, its own type, its own idea of what a tooltip is. The ten are MPStatistic, MPSparkline, and the eight that need a frame: line, area, bar, pie, scatter, gauge, heatmap and timeline.

They were built frame first, with one consumer. MPLineChart was written against internal/ChartFrame.tsx before any of the others existed, because a frame written for nobody fits nobody; the area chart then arrived against it unchanged, which was the test of whether that had worked. Two things came out of the seven that followed rather than being designed in — the chrome split into internal/ChartChrome.tsx when the pie turned out to need a legend and none of the axes, and a second value axis when the scatter turned out to need an x that was a position rather than a column heading. Neither could have been guessed at the start, and both were cheap once something concrete asked for them.

The colour was computed, not chosen. The obvious design was to rotate a series ramp off --mp-source-color, so a themed page got a themed palette for free. It was tried, measured, and it does not work: colour-vision deficiency collapses the red–green axis at absolute hues, so a ramp fitted to clear that axis puts a different adjacent pair straight onto it the moment the whole thing is rotated — four of five test seeds failed. The eight slots are fixed hues with only their lightness following the scheme, fitted until every adjacent pair clears ΔE 8 under protanopia and deuteranopia and ΔE 15 under ordinary vision, in both schemes. The heatmap's sequential ramp was fitted the same way against its own checks. Three is the cap where any two marks can touch — a scatter, a bubble, a heatmap — and no ordering of eight does better, which is why the scatter's marks carry shapes and the heatmap's cells carry a ramp instead.

Every chart ships a hover layer, a keyboard walk over the same readings, a clipped live region that is a sibling of the picture rather than a child of it, and the data as a table behind it. MPSparkline is the one exception and its documentation argues the case: it is thirty pixels tall with no axis, and a card floating over a mark that size covers the mark.

The other forty additions are the rest of the report: MPDataTable, MPTreeView, MPTreeSelect, MPTour, MPCodeBlock, MPMockup, MPHoverCard, MPToolbar, MPDataList, MPAppLogo, MPMeter, MPAnchor, MPScrollArea, MPScrollZone, MPPortal, MPFlex, MPStack, MPShow, MPFloatingBottomNavigation, four hooks, six more motion components, and four props — density, elevation, transition and classNames — given to every component that had the thing and no name for it.

One component was removed, which has not happened before. MPAvatarGroup was a special case of stacking that happened to be about faces; MPStack is the general one, and every existing use of the group is a MPStack with the same props. It is the only breaking change in the release.

Eleven defects were fixed, and four of them had been shipping in plain sight: eleven modules went out without "use client", one of which took a whole site down on a server component; the build never emptied dist/, so a deleted module went on being published; a picker asked for without its glyph could not be opened with a mouse at all; and a nested grid resolved the outer grid's column count. Three more were found by looking at rendered pages rather than at code, which is the argument for looking.

Added

  • MPTimelineChart, the last of the ten, and the only one whose marks have two ends. Every other chart here puts a mark at a single point on the value axis, which is why a span is its own type rather than an end bolted onto MPChartPoint — a field the other seven would carry and never read.

    The axis is a calendar rather than a number line. The 1-2-5-10 steps every other value axis uses put a tick every 200,000,000 ms on epoch milliseconds, which lands at 14:53:20 on an arbitrary Tuesday. Time is not decimal below the year, so internal/time-scale.ts steps in seconds, minutes, hours, days, weeks, months and years, walks the ticks with real calendar arithmetic — a month step lands on the first whatever the month's length, a day step survives the clocks going back — and aligns them, so a three-hour axis reads 00:00, 03:00, 06:00 rather than 01:00, 04:00, 07:00.

    The step is the one nearest the requested tick count rather than the first that is big enough, chosen by the ratio because the table spans nine orders of magnitude. Over a fortnight, "at least two and a third days" is a week — two ticks on the whole axis, which is what the first version drew.

    No legend: the rows are the category axis and are already named down the side, so legend is Omitted from the props rather than accepted and ignored. The pointer is measured to a mark's body rather than its centre, so anywhere along a bar counts — a span can be two hundred pixels whose centre a pointer never goes near. The arrow keys walk the spans, not the rows.

    Both ends of a span are rounded, which needed a new BarEnd: a bar grows from a baseline and has one data end worth softening, and a span has no baseline at all. The first attempt drew it as a left-rounded path unioned with a right-rounded one, which is a rectangle with four square corners.

    The frame gained the two things a chart whose marks are not a grid needs: a caller-supplied scale, and a caller-supplied table. A timeline's rows hold a variable number of spans, so a column per span would give the table as many columns as its busiest row; each row's cell writes its spans out instead.

  • MPHeatmapChart, and a sequential ramp to colour it with. The eight chart slots are an identity channel — they say which series, and nobody can tell whether slot 6 is more than slot 3 — so a cell that has to say how much needed a different kind of colour entirely.

    Five steps on slot one's hue, fitted the same way the categorical palette was and validated against the skill's ordinal checks: monotone lightness, adjacent steps at least 0.06 apart, one hue throughout, and the step nearest the page clearing 2:1 against it. Both schemes pass all four. That last check is what put the light end at 0.755 rather than somewhere paler, and the dark end at 0.405: the lowest cell of a heatmap has to be a reading rather than a hole.

    The scheme flips the anchor, not the hue. Pale to deep on a light page, deep to bright on a dark one, because "more" has to be further from the page in both — a ramp that ran the same way in the dark would put its high end where the eye reads nothing.

    Steps rather than a gradient, and the documentation says why: given a smooth fill a reader can say "darker" and nothing else, where five bands let them match a cell against the legend and come away with a number. The legend is therefore the ramp itself rather than a key.

    A null cell is an empty outline, not the lowest step. The bottom of a ramp is a reading, and painting "no data" as "the least" is the bridged line's mistake with the added problem that here it looks exactly like a real quiet hour. The cell under the pointer is ringed rather than recoloured, because a cell that changed colour on hover would be reporting a different number at the one moment it is being read.

    It also sidesteps the cap MPScatterChart ran into from the other side: every cell touches its neighbours, and colour separates only three touching things — but a ramp is not being asked to separate anything, only to be ordered.

  • MPGaugeChart, one reading on a dial, with the trouble drawn on the face.

    It is a meter in the markup, not a picture with a label — role="meter" carrying the value, the minimum and the maximum, exactly as MPMeter does, because they are the same quantity in two shapes. That is also why it has no table behind it and no hover layer: a chart of one number has nothing a reader could uncover that the figure in the middle is not already saying.

    thresholdColor moved out of MPMeter into internal/threshold.ts so both read it. Two copies of a six-line scan would agree until the day one was edited alone, and where the amber starts is exactly the thing a dial and a bar on one page must not disagree about. Its own file rather than a section of internal/chart.ts, because a bar is not a chart and should not be handed the scales and the path builders for six lines.

    bands paints the thresholds onto the track as well as into the reading, which is the argument for this shape over a bar: "how close is this to trouble" gets answered by where the reading stops rather than by arithmetic. The documentation says so, and says plainly that a gauge without thresholds is a statistic with a decoration round it.

    How far the arc hangs below its centre is computed from the sweep rather than assumed, so a 240° dial does not float in its box and a 300° one is not clipped; past 300 the two ends meet and the reader cannot tell start from finish, so that is where sweep stops. Both ends carry a tick, because without them a full dial and a dial with no scale look the same.

  • MPScatterChart, and the second value axis the frame did not have. Until now the category axis was either a row of columns or a set of evenly spaced ticks; xScale="value" makes it a scale of its own, so a point's x is a position rather than a column heading and two marks at the same index in two series are the unr...

Read more

Material Plus 1.6.0

Choose a tag to compare

@jooy2 jooy2 released this 04 Oct 08:03

One shape, found twice.

It started as two additions of a kind — a control that had the answer to a question nobody could put to it — then a report from an application that moved off Material UI, which turned out to be the same hole in twenty more places. The calendar could already draw a grid of months and a grid of years, and there was no way to say "stop there and hand me that". Eight controls were producing keystrokes and focus changes every second they were used, and no prop took delivery of one. A row could be a link but not a router's link; a chip could be pressed but could not be a menu's trigger; a grid item could be six columns wide but not "the rest of the row".

Then the library was audited against MUI, Ant Design, Chakra, Mantine, Radix and PrimeReact — and the finding was the same sentence again, now about the library talking to itself. A complete calendar lived in internal/. So did a window-class store, a reduced-motion store, a shortcut matcher and the rule for screen-reader-only text. Nine components drew themselves with that last one and no application could reach it. Almost everything in Added below is a name being given to something that was already running.

The one genuinely new thing is MPConfigProvider, and it is the item that was costing the most — not because it was hard, but because it was costing at every call site: a design that runs at size="sm" had to say so on several hundred controls, each of them a place it could be forgotten.

Ten defects were found on the way, and finding them is the argument for auditing by running rather than by reading. Four change what an existing page draws and are in Changed rather than Fixed, because that is the section people read before upgrading:

  • MPTextField swallowed Enter on every single-line field, so a <form> it was dropped into stopped submitting.
  • MPTypography painted on-surface when its own documentation said it inherits.
  • A pressable MPChip was a <span> holding a <button>, so it could not be a Base UI trigger.
  • An MPAccordion panel went on clipping its contents after it had finished opening.

The six in Fixed include three that had been shipping in plain sight. A picker's chosen day was never painted at all — two independent causes, either one enough on its own. Base UI was never told which way the page runs, so a slider's handle sat exactly one handle-width from the value it reported under RTL, at every value. And forcing a region back to light did nothing, which the documentation had been promising since the scheme switches were written. Not one of the three was visible in a class name; each took measuring a computed style.

Nothing was renamed and nothing was removed. Every existing prop still means what it meant in 1.5.0.

Added

  • MPConfigProvider — size, color and locale for a whole application. The gap that was costing the most, because it was costing at every call site: a design that runs at size="sm" had to say so on each of several hundred controls, and each of those was a place it could be forgotten.

    <MPConfigProvider size="sm" color="tertiary" locale="ko">
      <App />
    </MPConfigProvider>

    The resolution order is call site → group → provider → Material's own, so an MPButtonGroup still beats the page and a prop still beats everything. Ninety-eight resolution sites across sixty-five components changed from a destructuring default to that chain.

    Two axes and not three. variant is deliberately absent: MPButton starts filled, MPChip outlined, MPAlert tonal, MPAccordion outlined — five answers to five questions about emphasis, and one global value would overwrite all of them with an arbitrary one. size and color are the only axes where every component starts from the same answer, which is what makes a single default meaningful for them.

    The same rule decides which components read it: the provider supplies the library's default, not a component's answer. MPBadge keeps error, MPTooltip keeps sm, and MPDialog, MPPill and MPShortcut keep secondary — those are decisions rather than unfilled defaults. A prop with no default at all, like MPSkeleton's color, is left unset too.

    It also carries dir, which is the RTL wiring — see Fixed for what was not connected. And it carries locale so an application needs one provider rather than two. MPLocaleProvider is unchanged and still the narrow one; nesting merges per field, so an inner provider naming only a colour keeps the size from above.

    A note on the implementation, because the first version of it was wrong in a way worth recording: the resolver reads the context unconditionally. Written as prop ?? React.useContext(…) the context is only read when the prop is absent, so a control handed a size on one render and not on the next calls a different number of hooks. React said so out loud in the test run, and the fix was to read first and decide second.

  • MPPopconfirm — the same question useMPConfirm asks, kept where the control is.

    The difference between the two is where the reader's eye is, and it is a real one. A row of twelve delete buttons is the case for this shape: a modal that covered the table would take away the row the reader was pointing at, and having to re-find it afterwards is how the wrong row gets deleted. A confirmation about the page — leaving with unsaved changes — still wants the modal, because it is not about a thing on the page at all.

    It answers with a callback rather than a promise, which follows from the same difference: this one is written where the button is, so there is already a handler there to put it in. Escape, a press outside and the cancel button are all onCancel, on the rule the modal already follows — the safe answer to "are you sure" is no. Opening is not an answer and fires neither handler.

    It is a popover and not a dialog, so it does not trap the focus and a reader can Tab away from the question. That is the trade the shape makes, and it is why the modal is still the one for an answer that has to be given. The labels come from the confirm namespace added above, so both components say the same words in the same eighteen languages.

  • MPImage — a picture that says what it is doing.

    A bare <img> has three states and shows two of them badly: while it is on its way there is a hole the size of nothing and the page jumps when it lands, and when it fails there is the browser's own broken-image mark, which is different in every browser and belongs to none of them. This draws a placeholder for the first and a fallback for the second, and ratio reserves the room so that neither one moves the page.

    The cached case is the one that breaks, and it is the reason this is a component rather than three lines of useState at a call site: an image already in the cache is complete before React attaches anything, so its load event has been and gone. A component that only listened would hold its placeholder over a picture that is fully drawn — on every second page view, which is the view nobody tests because the first one works. naturalWidth is what tells the two kinds of complete apart.

    alt is required, for the reason MPIconButton's label is: a picture with no text alternative is the most common accessibility defect a library can actually help with, and the help is refusing to compile. alt="" is how to say decoration.

    preview makes the box a button and opens the picture over a scrim — off by default, because one that silently became pressable would be a control nobody declared, and refused outright on a picture that failed. previewSrc is what makes a thumbnail worth being one.

    Not a gallery and not a next/image: a lightbox that walked between pictures would be a component holding a collection, and srcset generation belongs to whatever is serving the file. One new glyph, broken-image.

  • MPStepper and MPStep — the sequence a reader is working through, as opposed to the one MPTimeline shows them having finished.

    The two draw the same picture, and now from the same table: the bullet, the connector and the ladder they sit on moved to internal/step.ts, because a picture kept by two tables is a picture that drifts the first time one of them is edited alone. What makes them two components is that one is read and the other is pressed — and that a stepper mounts one panel, which is the half that matters for a form, since keeping the others would mean hidden fields that still submit.

    linear is on by default and going back is always allowed. What it refuses is jumping forward past the step the reader has actually got to — the furthest step rather than the current one, so a reader who reached step three and looked back at step one can still return to three. A stepper that only knew active would have taken that away from them.

    Three decisions worth naming:

    • No Next and Back. What next means is whether the current step validates, and a library that drew those buttons would have to guess that or ask for a validator per step. onActiveChange and two MPButtons is four lines the caller can read.
    • error is a colour, not a fourth state. Where a step sits in the run and what happened to it are two questions, and a sequence with a hole in it is one the reader cannot count — so a failed step keeps its place and swaps its accent family.
    • optional takes a node rather than a boolean. A boolean would mean shipping the word Optional and then translating it into eighteen languages for a label only some applications draw.

    A stepper with no onActiveChange is unpressable — a progress indicator for a sequence the application's own buttons drive.

  • MPConfirmProvider and useMPConfirm — "are you sure?" as a promise.

    const sure = await confirm({
      title: ...
Read more

Material Plus 1.5.0

Choose a tag to compare

@jooy2 jooy2 released this 04 Oct 08:03

Twelve components could not be handed a className, and ten of them were the form controls — the ones a page is most likely to want to place. That is this release: the prop, the props tables that never mentioned it, and a section saying what passing one actually does. The last of those turned out to be worth measuring rather than reasoning about, and the measurement corrected the first thing written about it.

Nothing was renamed and nothing was removed. MPBox, MPButton and MPTextField weigh what they weighed in 1.4.0, to the byte.

Added

  • className and style on the twelve components that took neither. MPTextField, MPSelect, MPCheckbox, MPSwitch, MPSlider, MPRadioGroup, MPRadio, MPNumberField, MPFilePicker, MPSegmentedButton, MPMenubarMenu and MPNavigationMenuItem. A hundred of the hundred and seventeen exported prop types already accepted both, by extending React.ComponentPropsWithoutRef; these twelve did not, because they do not spread rest props onto an element they own. It is a hundred and twelve now, and the five that still do not are the two mixins and the three providers that render no element at all.

    It was less an oversight than a question nobody had answered. A control that draws a box, a label, an input and a supporting line has four elements a class could land on, where the other seventy-odd components have one. The answer is the outermost element in every case, and each prop says which element that is in its own words, because it is a different element each time: the box around the control and its supporting line for a field, the box around the tick for a checkbox, the word on the bar for an MPMenubarMenu, and for an MPNavigationMenuItem the word in the row — the link, or the trigger that opens the panel — never the panel itself, which is portalled out of the element entirely.

    style is the half that carries more weight than it looks. It is inline, so a --mp-sys-* token handed to one instance beats every stylesheet no matter how the sheets were ordered, which is what makes it the answer below rather than a convenience. Where a root already carried accentSlots() — the checkbox, the switch, the slider, the radio group — the caller's object is spread last, the way every other component in the library already does it, so an accent family survives being given a margin.

    MPMenuGroup and MPMenuRadioGroup took a className and no style, which was the same pair half-finished. Both take it now.

Changed

  • className and style are in the props tables, on all hundred and five components that accept them. Neither appeared anywhere before, so the only way to find out whether a component took one was to try. The two rows are derived rather than typed out a hundred and five times — the mechanism the shared size and color rows already use — and a table describing something with no element behind it is left alone: the two providers, and the three plain objects that are entries in somebody else's array.

    Twelve components get a different sentence, collected in one map. Eleven of them are portalled, and that is the whole reason the map exists: what a reader sees of an MPTooltip is its plate, and the plate is not inside the element the trigger sits in, so the outermost element it draws would be naming a box nobody can point at. The twelfth is MPSegmentedButton, where the class goes to the set and not to a segment.

What passing a class actually does

The class is concatenated, not merged. Yours is appended to the component's own and nothing is removed to make room for it, so two classes setting the same property both land on the element at equal specificity and the winner is whichever one the stylesheet put last.

A class for something the component does not already set always applies, and that is most of what a class is for. Past that it depends on the pair, which was the surprise. Measured by rendering the components and reading the computed value back, on an MPButton at the default size:

You pass It sets Result
px-8 px-6 applies
px-2 px-6 ignored
h-20 h-14 applies
h-8 h-14 ignored
text-lg, text-xs text-mp-title-medium applies
bg-red-500 bg-(--_mp-accent) applies
rounded-lg rounded-mp-full ignored
shadow-lg shadow-mp-1 ignored
p-8 px-6 ignored
any of the above with a ! — applies

The first four rows are the ones to read. px-8 works and px-2 does not, on one component and one property, because Tailwind emits a scale in scale order and the larger step is therefore later — so making a control bigger tends to work and making it smaller tends not to. The rest is which of two theme keys sorted first: the library's --text-mp-* land before Tailwind's own, so any text-* wins; --radius-mp-* and --shadow-mp-* land after, so rounded-* and shadow-* do not.

None of it is a promise. It is what one version of Tailwind emits, it is per pair rather than per property, and it moves when either side adds a token. To take something over there are three answers that do hold: the token through style, the prop that owns the axis — size is a height and a type scale and a set of paddings — and Tailwind's !, which took all three of the losing pairs above.

The library ships no class merger of its own, and that is a decision rather than an omission. tailwind-merge is the right tool for this and a good one, but it would be a runtime dependency on every component, against 2.9 kB for a button on its own, and its class groups would have to be taught every mp- token this package adds, in step with adding them. Merging at the call site is one line and costs the projects that do not need it nothing.

The measurements

esbuild, gzip, React and @base-ui/react external — this library's own contribution, on the same harness as 1.4.0, and with 1.4.0 rebuilt from its own commit rather than quoted, so the difference is the change and not drift.

Scenario 1.4.0 1.5.0
MPBox 0.4 kB 0.4 kB
MPButton 2.9 kB 2.9 kB
MPTextField 4.3 kB 4.3 kB
Five components 7.3 kB 7.4 kB
Ten components 11.1 kB 11.2 kB
Everything 74.1 kB 74.2 kB

A hundred grams of destructuring spread over twelve components. The three single-component rows do not move at all, because none of the twelve is among them.

Every stylesheet figure is unchanged — 113.5 kB whole, 16.2 kB gzipped, eighty-eight split sheets, the crossover still at about thirty-one components. The release added props, not classes, so there was nothing new for Tailwind to generate.

The suite goes from 1633 tests to 1644: one passthrough block per component, in the shape MPEmpty, MPTooltip and MPShortcut already used. Four of them assert that the accent slots survive the caller's style object, two that a container's class does not leak onto the option or the word inside it, and one that a set's class did not land on a segment.


Every release of this package is in the changelog on the docs site and in CHANGELOG.md at this tag.

Material Plus 1.4.0

Choose a tag to compare

@jooy2 jooy2 released this 04 Oct 08:03

Seventeen new components, and five of them are one thing: the skeleton a page is hung on. The library could draw a text field and could not draw the page the field was on — there was no <header>, no <nav>, no <aside>, no <main> and therefore no way to build a page whose regions a screen reader can list, a reader mode can find or a crawler can tell apart. That is what the first half of this release is. The second half is what kept turning up next to it: two strips of navigation, two form primitives, a toggle, a transfer, a command palette and a stack of faces.

The floor did not move. A project that renders one component pays what it paid in 1.3.1, to the byte — see the measurements at the end.

Added

  • MPPageLayout, and the four parts that hang off it: MPHeader, MPFooter, MPSidebar and MPSidebarTrigger. The layout contributes exactly one element of its own — a <div> with no meaning — plus the <main> and the skip link that jumps to it. Every other landmark comes from whatever was handed to a slot, which is why the other three are real <header>, <footer> and <aside> elements rather than styled boxes.

    Everything that decides where a column goes is flexbox and a media query, so the arrangement is right in the first frame the browser paints and right on a page whose JavaScript never arrives. Two things are measured and only two — the header's height and the footer's — because a column that holds its place has to start below a bar whose height nobody but the bar knows, and those numbers are written straight onto the root as custom properties rather than held in state, since a setState per resize would re-render the page to change a top.

    collapseBelow is MPWindowClass rather than a pixel width, and it defaults to expanded, because that is MD3's own answer: the specification gives a standard navigation drawer to an expanded window and puts the same destinations behind a modal drawer below one. An MPSidebar is therefore one component in both shapes — a column above the line and an MPDrawer below it — with the children existing once either way, so nothing inside is put into the document twice for a screen reader to read twice. Which of the two is showing is answered in CSS for the first paint and by matchMedia from then on, because a modal drawer is a portal into document.body and there is no body to portal into while the markup is being rendered.

    The sidebar's width ladder is MPDrawer's to the pixel — md is MD3's own 360dp navigation drawer — so a column is exactly as wide as the drawer it becomes.

  • MPNavigationMenu and MPMenubar, which are the same strip and differ in what a row is. The navigation menu holds links, so it is a <nav> full of real <a> elements — which is what puts a destination in a screen reader's link list, on the status bar, on the middle-click menu and in a crawler's index. The menu bar holds actions, so it is Base UI's menubar role with one tab stop for the whole strip. The rule is short: a menu when the row does something, this when the row goes somewhere.

    Both draw no surface. A menu bar sits on an MPHeader or a title bar, and a sheet under a strip that is already on a sheet is two sheets. The navigation menu's panel is MD3's menu surface — surface-container at elevation 2 under corner-extra-small, the same three decisions MPMenu and MPSelect make — and it animates its width and height as well as its opacity, because Base UI resizes one open panel into the next rather than closing and reopening it, and animating that is what makes crossing the row read as one surface rather than three sheets flashing.

  • MPForm and MPFieldset. The form owns the part that has to live above the fields: a submit collects every field's validity at once, focus lands on the first that failed, and errors puts a server's answer back on the field it belongs to rather than in a banner at the top of the page. It is not a form library — there is no schema here and no resolver — because every one of those can already produce { [name]: message }, which is the seam this is built around.

    The fieldset owns the one thing only a real <fieldset> can do: disabled reaches every control inside it, including ones a component three levels down rendered and never heard of it. That is not something a React context could promise, and it is the whole reason it is a component rather than a <div> with a heading over it. Neither draws a surface; a group of fields is a grouping and not a sheet.

  • MPToggle and MPToggleGroup. A button that stays down, and the rule that decides how it is drawn is that off is neutral. A toggle has to be readable at a glance in a row of eight, and the axis a reader can judge in isolation is hue rather than saturation — a set where off is a paler accent and on is a stronger one is a set nobody can read without holding two of them side by side. It is also what leaves color meaning something: on a button the family says what kind of action this is, here it says what "on" looks like.

    The group cuts the corners that face a neighbour out of MPButtonGroup's own table, and it holds the value, so variant, size, color and disabled are set once for the run.

  • MPTransfer — two lists and the arrows between them, for a choice long enough that a combobox with forty chips in its field stops being readable and a column of forty checkboxes gives no answer to "what did I pick". The interaction is one sentence: ticking is not choosing. value is which side a row is on; a tick is a mark saying it should move next time an arrow is pressed. So onValueChange fires on the arrow and never on a tick, moving drops the ticks on what moved and keeps the rest, and a press moves only what the filter is still showing — a row that was ticked and then hidden was never part of it.

  • MPCommandPalette. Everything an application can do behind one field, for the point where a product has more actions than a menu bar can hold. What comes back from an MPCombobox is a value the caller then does something with; what comes back from here is something happening. Its surface is MD3's docked search view — surface-container-high at corner-extra-large under elevation 3 — which happens to be the same three decisions MPDialog makes, and it is pinned near the top of the window rather than centred, because it is opened by somebody who is about to type.

    Mod+K is bound on the window and is written in exactly the vocabulary MPShortcut draws, which is the point: a shortcut a component displays and one it binds have to be spelled the same way, or the label on the screen is a claim nobody checked. A command's own shortcut is a label for a binding the application already has — the palette deliberately does not bind it, since a palette that did would be competing with the editor underneath it.

  • MPAvatarGroup. A stack of faces with the ones that did not fit as a count, and the ring between them is not decoration: two circles of similar tone laid over each other have no edge at all and the stack reads as one smeared shape. It is drawn in the page's own surface, so what separates the faces is the background showing through rather than a white line on top of them, and it goes dark with the scheme without anybody saying so. total is the prop worth reaching for — a list of forty people that ships four <img> tags and a number.

  • Four message namespaces and one glyph. layout, transfer, command and colorPicker bring the table to thirteen, each still its own module under internal/messages/ so a component pays for the words it speaks and no others, and each translated into all eighteen languages. Eight of the new strings are drawn rather than only announced — the skip link, the transfer's two headings and its empty line, both placeholders, and the word each of the two pickers shows when there is nothing yet — which is the class of string it would be least forgivable to leave in English. ICONS gains a thirtieth glyph, three lines: the one drawing in the set that is a picture of a menu rather than a picture of what pressing it does, and the one shape thirty years have made legible with no word beside it.

Fixed

  • MPColorPicker could not say its own names in anything but English. Its seven strings — the saturation square, the two rails, the value field, the swatch grid, the ×, and the word the trigger shows before a colour is chosen — were defaults kept inside the component, with no locale prop and no route into the message table. Every other component in the library that has to invent a word takes locale, and the localisation guide says so, so this was the documentation being wrong about one component rather than a deliberate exception. They are the colorPicker namespace now: locale gets a translation, MPLocaleProvider gets one for a whole application, and labels still wins over both. MPColorPickerLabels is now an alias of MPMessages['colorPicker'] and is structurally what it always was.

    One behaviour change, and it is the point of the fix: a picker inside an MPLocaleProvider whose language has a registered table now speaks it. English is unchanged to the string.

  • A field could not show an error it had been handed from outside. Every field here renders its supporting line from its own errorMessage prop, and only from that — so an error arriving through Base UI's Field, which is how MPForm's errors reaches a field, set the outline red and printed nothing at all. A control that is visibly wrong with no explanation of why is the exact failure errorMessage exists to prevent, arriving through the other door.

    MPTextField and internal/SupportingText.tsx now render an empty Field.Error in the slot when they have nothing of their own to say. Base UI draws it only when the field is genuinely invalid, so...

Read more

Material Plus 1.3.1

Choose a tag to compare

@jooy2 jooy2 released this 04 Oct 08:03

Fixed

  • The package could not be imported by webpack, or by Node. package.json says "type": "module", which makes every .js in dist/ a strict ES module — and a strict ES module has to name the file it imports. tsconfig.json compiles with module: "Preserve", which emits each specifier exactly as the source wrote it, and the source writes them the way TypeScript's own bundler resolution reads them: export * from './components/button', no extension. Bundling MPButton with webpack 5 produced 78 Module not found errors, one for every specifier it reached, each of them the fully specified breaking-change notice — that is Next.js, Rspack and every Create React App descendant. import('material-plus-ui') in Node threw ERR_MODULE_NOT_FOUND on the first line of dist/index.js, which is any server-side render that does not go through a bundler first.

    It survived a release because Vite, esbuild and Rollup-with-node-resolve all guess the missing extension, and the documentation site, the test suite and 1.3.0's own measurements are Vite. scripts/specify-imports.mjs now rewrites the 884 specifiers in dist/ — the .d.ts files too, which had the same problem for a consumer on moduleResolution: node16 — and reads the output back afterwards rather than trusting itself, because what this guards against is not a build that crashes. It is a package that installs, type-checks and cannot be imported. webpack goes to zero errors, Node imports all 131 exports, and the three bundlers that already worked produce what they produced before.

  • The package broke a Next.js App Router build the moment a server component imported it. Not a warning and not a runtime nuisance — the build stopped:

    Error: Failed to collect page data for /
    cause: TypeError: e.createContext is not a function
    

    from a page.jsx whose only line was import { MPButton } from 'material-plus-ui'. React ships two builds and the one a server component renders against has no createContext, no useState, no useContext, no useRef and no useEffect; a module that reaches for one has to say "use client", and no module here said it. That is every App Router project, on the component a page starts as.

    scripts/mark-client.mjs now derives the set and writes it into dist/: 76 of the 200 modules, which is every module that renders. It runs after terser, because terser deletes the directive — compress.directives is on by default and reads "use client" as a redundant prologue.

    Two things it deliberately does not mark. The barrels: dist/index.js re-exports the whole library, and a directive there would make one import of MPBox pull all of it across the boundary — a re-export of a client module from a server one is exactly what a boundary is, and every bundler follows it. And the data: internal/i18n.ts, the nine message tables and the eighteen locales, so registerMPMessages can be called from wherever an application does its setup.

    Verified rather than reasoned: twenty-eight pages, one per component and every one of them a server component, now prerender through a real next build. The directive costs nothing — a bundler removes it, and the gzipped figures below are the same to the byte with it and without.

Changed

  • English is nine modules instead of one object, and a component carries the namespace it speaks. 1.3.0 moved the eighteen translations out of the library so that a component saying one word did not hold every word in every language. English stayed, as one MPMessages object with a namespace per component in it — and one object is indivisible. A bundler can drop a module nobody imports and an export nobody reads; it cannot drop a property. So MPButton, which says Loading, went on shipping the calendar's eighteen strings, the pager's seven and the chat bubble's six: 1.75 kB of table for the 130 bytes of it that got rendered.

    The strings are src/internal/messages/, one module per namespace, and resolveMessages(locale)[namespace] is now resolveNamespace(namespace, locale) — handed the English table rather than looking it up, which is the whole of why the other eight can be left out. A call site reads useMPMessages(COMMON, locale), and the namespace object carries its own name so it cannot say one namespace while passing another's words. All of this is internal: registerMPMessages, MPLocale, MPPartialMessages, MPLocaleProvider, every labels prop and every registered translation behave exactly as they did, and the merge is still a namespace at a time so a partial language still falls back to English one namespace at a time.

  • The icons are two modules now, and that is what makes ICONS work in a server component. A component handed to a client component as a prop has to be a client reference — otherwise <MPIcon icon={CheckIcon} /> in a server component fails with Functions cannot be passed directly to Client Components, pointing at a forwardRef object, because lucide-react marks its Icon module and not its icons. But ICONS is read with a property access, and a server component cannot read a property off a client module's namespace either: it gets undefined and React reports an invalid element type. Whichever way the single file was marked, one of its two halves broke.

    So constants/glyphs.ts is the twenty-nine renamed lucide exports and carries the directive, and constants/icons.ts re-exports them and holds the table without one — a plain server-side object whose values are client references. material-plus-ui/constants/icons is still the path, the named exports are still what the components import, and both halves now work from a server component. Bundles are unchanged: MPButton is the same 23 modules and the same 2.9 kB, because ICONS is still an export nobody reads.

  • The build marks a class list pure as well as a forwardRef. A long list of Tailwind utilities is written here as an array of lines joined with a space, which is a readable way to spell a hundred characters and — to a bundler — a method call on an array. ['a', 'b'].join(' ') cannot be proved harmless any more than forwardRef(fn) can, so every such constant in a module was kept the moment anything in that module was imported. internal/surface.ts is where it showed: MPBox renders a <div> and reads one export from it, and was carrying FADE and SHEET_MOTION — the motion classes for the portalled surfaces, which an MPBox has none of — for 60% of its bundle. scripts/annotate-pure.mjs annotates the 24 of them, finding the array by matching brackets rather than by a pattern, since [&>svg]:size-full inside a string would end a naive one in the wrong place. It refuses to annotate an array with a call in it, so the day one has a side effect it is not quietly marked away.

Added

  • npm run measure, and the build runs it. scripts/measure-bundle.mjs bundles dist/ with esbuild for six fixed scenarios and prints the gzipped size of each, the way build-split-styles.mjs already prints where the split stylesheet stops paying. The reason it is a bundler and not unpackedSize is 1.3.0: a package with no sideEffects field shipped a 62.3 kB bundle for an MPBox, and nothing about the published files said so. It also checks that material-plus-ui/components/button still costs what the barrel costs — the subpath exports are documented as insurance rather than as a recommendation, and that is only true while the two are the same.

The measurements

esbuild, gzip, React and @base-ui/react external — this library's own contribution, measured on the same harness before and after. Rollup and webpack 5 were run beside it and agree within a few hundred bytes; webpack could not be run against 1.3.0 at all, which is the first entry above.

Scenario 1.3.0 1.3.1
MPBox 0.5 kB 0.4 kB
MPButton 3.4 kB 2.9 kB
MPTextField 4.6 kB 4.1 kB
MPDatePicker 10.0 kB 9.6 kB
Five components 7.8 kB 7.3 kB
Ten components 11.5 kB 11.1 kB
Everything 64.2 kB 64.1 kB

The last row barely moves, and that is the shape of the whole release: nothing was removed, so a project using all of it pays what it paid. What changed is the floor — what a project pays before it has used anything in particular.

What a server component still cannot do

Two things, and both of them are React's rules rather than this library's. Hooks — useMPSnackbar, useMPLocale — only run in a client component; the providers they belong to render from a server layout perfectly well. And a callback cannot cross the boundary: <MPButton onClick={…} /> needs "use client" on the file that writes it, exactly as a bare <input onChange> does.

Consumers on a bare Rollup build will see MODULE_LEVEL_DIRECTIVE warnings, one per marked module. Every library that supports React Server Components produces them, and onwarn filters them. Vite, esbuild, webpack and Next.js emit nothing — checked, not assumed.

Measured and left alone

  • The six motion tables in internal/animate.ts. ANIMATION_CLASS, DURATION_TOKEN and EASING_TOKEN are keyed by effect, so an MPAnimateFade carries all six effects' rows and reads one. Split per effect it is 1.39 kB against 1.29 kB — 102 bytes, on seven components and on nothing else. The three tables are the one place the specification's reasoning is legible as a set: five of the six decelerate, grow and zoom share a keyframe. That reasoning is worth more than the hundred bytes.
  • lucide-react's shared runtime, which is 1.44 kB on every bundle that draws a glyph — Icon, createLucideIcon, the context and five case-conversion helpers, for an MPButton that draws one spinner. Vendoring the twenty-nine glyphs this library uses woul...
Read more

Material Plus 1.3.0

Choose a tag to compare

@jooy2 jooy2 released this 04 Oct 08:03

Changed

  • The package now says sideEffects, and that one line is most of this release. Without the field a bundler has to assume every module in a package might do something on import, so a barrel of export * — which is what src/index.ts is — pulls all 172 of them in and keeps whatever it cannot prove inert. Measured with esbuild and with Rollup, against React and @base-ui/react as externals: importing MPBox, which renders a <div> with three classes on it, produced a 203-module, 62.3 kB gzipped bundle. So did importing MPButton. So did importing five components, and ten. Using one component out of ninety-four cost 86% of using all of them, which is another way of saying the library was not tree-shakeable at all and had been telling people it was. It is ["**/*.css"] rather than false, because a bare false is what lets a bundler drop import 'material-plus-ui/styles.css' on the grounds that nothing reads its exports.

  • The build marks every React.forwardRef and React.createContext call pure — 86 of them — and terser.config.json keeps the annotations rather than consuming them. sideEffects lets a bundler drop a whole module; this is what lets it drop one export from a module that has several. Nine files hold more than one component, and MPTab on its own went from 4.48 kB to 2.19 kB. It is scripts/annotate-pure.mjs over the emitted JavaScript rather than a comment in the source, because the annotation sits in the middle of a declaration line that is already 87 columns against a limit of 100 — written in src/, Prettier's answer to the overflow re-indents thirty component bodies, which is four thousand lines of diff and the blame history of every file it touches in exchange for a comment that says the same thing sixty-eight times. The script counts what it annotated against what the source declares and fails the build if the two disagree.

  • resolveMessages reads a registry instead of a built-in table, and the eighteen translations moved to src/locales/. This is the breaking change. A component that says a single word — MPButton says Loading — held a static import chain down to every string in every language, and a bundler cannot drop data that something imports: 32 kB of the 38 kB an MPButton bundle weighed was Thai, Hindi and sixteen others. The tables are modules now and nothing in the library imports them, so an application hands over the ones it speaks:

    import { registerMPMessages } from 'material-plus-ui';
    import { ko } from 'material-plus-ui/locales';
    
    registerMPMessages(ko);

    After that call locale="ko" resolves exactly as it did before, on a provider or on a component, because it is the same table. registerMPMessages(...LOCALES) restores the old behaviour in one line and costs exactly what the old behaviour cost. A tag nobody registered falls back to English the way an unsupported tag always has, so the failure mode of forgetting the line is English words and never a broken render — and MPLocale is a plain object, so a language this library does not ship is now a supported case rather than eighteen labels props.

Added

  • material-plus-ui/locales, and material-plus-ui/locales/<tag> for one language at a time. Named exports under their tags, hyphenated ones camel-cased (zhHans, zhHant), plus LOCALES for all eighteen as an array. English is about a kilobyte and each language after it about six hundred bytes gzipped.
  • registerMPMessages, MPLocale and MPPartialMessages are public. The first is the whole of the new API; the other two are what you write a table of your own against. aliases on an MPLocale is how one table answers to several tags — it is what zh-hant uses for zh-TW, zh-HK and zh-MO, and it replaces the alias map that used to live in internal/i18n.ts.
  • material-plus-ui/components/<name> reaches a component without going through the barrel. import { MPButton } from 'material-plus-ui/components/button' and the root import now produce byte-identical bundles, so this is insurance rather than a recommendation: a bundler that gets tree-shaking wrong, or a build that does not read sideEffects, has a path that cannot go wrong.
  • material-plus-ui/styles/tokens.css and a sheet per component. dist/styles.css is unchanged and is still the right answer for most projects, but it is 109 kB whether a page renders one component or all of them, because Tailwind generates from a file scan and not from an import graph. The same rules are now also cut along the seams the components are: the tokens once, and material-plus-ui/styles/button.css beside it. One component is 3.8 kB gzipped against 15.6 kB for the whole sheet, five is 5.5 kB, ten is 8.0 kB. The sheets repeat each other's utilities, so the total climbs faster than the whole sheet's does and passes it at around thirty components — npm run build measures where that crossing is and prints it, so the figure in the docs cannot quietly stop being true.
  • scripts/build-split-styles.mjs, which fails the build rather than trusting itself. A component's sheet is decided from its own import graph and from which mp- class names its files spell, both read out of the source. Two things are then checked: that every selector a scan of all the sources produces exists in some split sheet, and that no sheet reads a custom property neither it nor tokens.css defines. Both failures would otherwise be invisible — the component renders, the page is merely wrong — and neither can survive a build now.

Notes on the measurements

Every figure above is gzip, from a real bundler over a real install, with React external. The marginal column is what this library costs on top of @base-ui/react; the total column adds Base UI in.

Scenario Before After Before, with Base UI After, with Base UI
MPBox 62.3 kB 0.5 kB 153.2 kB 2.3 kB
MPButton 62.3 kB 3.4 kB 153.3 kB 6.2 kB
Five components 63.1 kB 7.4 kB 154.1 kB 33.6 kB
Ten components 64.0 kB 11.8 kB 161.6 kB 81.1 kB
All ninety-four 72.4 kB 64.3 kB 202.1 kB 193.6 kB

The last row is the one that did not move much, and that is the point: nothing was removed. What changed is that the bill is now itemised.


Every release of this package is in the changelog on the docs site and in CHANGELOG.md at this tag.