Skip to content

Material Plus 1.1.0

Choose a tag to compare

@jooy2 jooy2 released this 04 Oct 08:03
· 574 commits to main since this release

Fixed

  • A refused pointer capture took MPPanes down with it, and took the page's text selection with it. beginDrag captured the pointer before anything else, and setPointerCapture throws NotFoundError for an id that is not an active pointer — which is reachable whenever the press is over before React's handler runs, and is what a synthesised pointerdown produces in Firefox every time. The exception abandoned the rest of the function: no data-dragging, no move listeners, and document.body left user-select: none with no drag on screen to explain why and no end coming to hand it back. The capture is attempted and allowed to fail now. It is genuinely load-bearing — the listeners are on the handle, so without it a boundary stops following a pointer that has moved off it — but a drag that only works over the handle is a smaller problem than a page nobody can select.

Added

  • Eleven motion components: MPAnimateFade, MPAnimateGrow, MPAnimateZoom, MPAnimateSlide, MPAnimateRotate, MPAnimateBlink, MPAnimateAppear, MPAnimateLighting, MPAnimateMarquee, MPAnimateHeadline and MPAnimateTyping. Every one of them is a wrapper around content that was already going to be there, and every duration and curve it does not take is a Material motion token rather than a number — medium2 for an arrival, short4 for an exit, long2 for half a turn, the emphasized set for anything entering or leaving a screen. They share one set of props (duration, delay, easing, repeat, alternate, paused, and a trigger of mount / visible / hover / manual), so learning one is learning all eleven.
  • The machinery is internal/animate.ts and one @keyframes per effect in src/styles.css, and it generates no CSS. Each effect runs from a from-state written entirely in --_mp-anim-* custom properties to the element's natural one; the module fills slots and the stylesheet decides what they mean. That is the split accentSlots() already makes for colour, forced by the same constraint — Tailwind only ever sees class names that appear literally in the source, so a class per duration would not survive the first prop. A duration nobody sets resolves to var(--mp-sys-motion-duration-medium4) rather than to 400ms, because only one of those two moves when a page retunes its motion tokens.
  • mode="out" is free, and correct. Because the from-state is in the keyframe rather than in a second class, running an effect backwards is animation-direction: reverse and nothing else — so all five of the effects that take a mode get an exit without a second rule. CSS mirrors the timing function along with the frames, which means an entrance eased on emphasized-decelerate comes back accelerating: exactly the pair MD3 asks for, supplied by the browser. fill-mode: both throughout, so a faded-out element stays out instead of snapping back the moment the animation ends, and an untriggered one sits paused on its own first frame without a second class to describe waiting.
  • MPAnimateAppear writes the animation onto the children themselves, never onto wrappers. That is the whole design constraint rather than an implementation detail: a component that wrapped each child would break every layout that cares what its children are — flex and grid, <ul>/<li>, a table, a primitive that walks its own children — and would break them silently, the moment somebody added an animation to a list that had been fine. Only a bare string has no element to write onto, and that one gets a <span>. The stagger is per child, which makes grouping the way to opt part of a list out, and the travel is 0.75rem — a settling rather than an entrance, because a long travel repeated down eight rows turns the block into something moving and leaves a reader chasing the row they wanted.
  • The four continuous effects each solve the problem that stops them being a keyframe and a number. MPAnimateLighting animates a conic gradient's from angle — only animatable because the angle is a registered custom property with a type — rather than rotating the pseudo-element, since a rectangle spun about its centre is wider than the rectangle and swings its corners out on every quarter turn; the light sits at z-index: -1 in an isolated stacking context so it reads as a glow escaping from under the edges rather than a border drawn around them. MPAnimateMarquee lays the content down twice and travels each copy its own length plus the gap, so there is no seam and nothing is measured — a percentage translate resolves against the element's own box. MPAnimateHeadline puts every line in the same grid cell and hides the ones not showing with visibility rather than display, so the reel is as large as its longest line from the first frame and never resizes the page under itself. MPAnimateTyping advances by grapheme rather than by code point, because 👩‍👩‍👧 is seven code points and a 한 typed on a Korean keyboard can be three.
  • A marquee takes a speed, not a duration. A duration would put a strip of four logos and a strip of forty across the same box in the same time, with the long one arriving as a blur; pixels per second moves both at the pace of a reader. The strip is re-measured through a ResizeObserver, and the gap is read back off the computed style rather than parsed from the prop, since '2rem' is only a number once a font size has been resolved. pauseOnHover is on by default and is not decoration: content moving past a pointer cannot be clicked, and a link inside a marquee that never stops is a link nobody can follow.
  • Every effect answers prefers-reduced-motion, and each answer is a decision rather than "off". The CSS ones are switched off wholesale in the stylesheet; the two written in JavaScript read the query through useSyncExternalStore, because a media query is an external store and reading it in an effect would start a typewriter for one render in front of a reader who asked not to see it. Where stopping would destroy the message the effect stops in a state that still carries it: a lighting arc becomes an even glow that still marks the live row, a marquee holds still because a row of logos that has stopped is still a row of logos, and a typewriter simply shows its text, caret included.
  • MPAnimateBlink is documented with the argument against it. It is the one kind of motion this library otherwise refuses — a reduced-motion reader sees none of it, and min={0} makes the element genuinely absent for half of every cycle — so the docs say plainly that the pulse must never be the only thing carrying the message. Its cycle is symmetric (full, faint, full) so that however many times it runs it ends where it started; a keyframe that stopped at faint would leave the element permanently half drawn, which reads as a rendering fault rather than as an effect that has finished. It is also the only effect whose repeat defaults to infinite, because a single blink is a flicker and nobody asks for a flicker.
  • Two of them exist because a rotation and a zoom are each two gestures, and one of them is deliberately not built. MPAnimateRotate takes from and to, so one component covers both an arrival that swings into place and an endless spin that starts where it ends — a second component for the second case would be the same keyframe under another name. MPAnimateZoom and MPAnimateGrow share one keyframe for the same reason, and differ only in distance and anchor: 0.4 about the centre against 0.8 about wherever a caller points. MPAnimateZoom has no origin on purpose, and writes the centre onto the element rather than leaving it to the stylesheet, because a zoom anchored to a corner is a grow and an inherited transform-origin should not be able to quietly turn one into the other. What is not here is MD3's container transform: the specification's version morphs one element's bounds into another's, which needs both elements and a shared identity between them, and a wrapper can only honestly offer the half one component can do alone.
  • A Motion group in the docs and the gallery, and the shared MPAnimateProps rows in the props table. Added with the first of the eleven and joined by the ten after it.
  • Seven components: MPGrid, MPGridItem, MPContainer, MPTabs, MPFloatingActionButton, MPBottomNavigation and MPPagination, plus MPRating. Where MD3 defines the component the specification is followed to the number — a primary tab is 48dp with a rounded 3dp indicator under its label and 64dp once a glyph sits above it, a floating button is 56dp at corner-large under primary-container, a navigation bar is an 80dp surface-container with a 64×32dp secondary-container pill behind the glyph of the destination you are on, a container's margin is the specification's 16dp — and where it does not, the component is built out of the specification's own roles and this library's own ladders rather than out of sizes invented for it. This is the layout half of the library arriving at once: until now there was a sheet (MPBox) and a splitter (MPPanes) and nothing that answered "how wide is the page" or "how does the content divide itself up".
  • The grid changes at Material's window size classes, not at Tailwind's breakpoints. span, offset, columns and the gutters all take either a value or a map keyed by compact / medium / expanded / large / extra-large — 0, 600, 840, 1200 and 1600dp, which is the axis MD3's layout grid is actually described along. Tailwind changes at 640/768/1024/1280, which is a different set of numbers for the same idea, and a grid reflowing at one set while the md: utility beside it reflows at the other is a layout that is subtly wrong at exactly one width and impossible to reason about at every other. Given two ladders the library takes the specification's, and documents how to line the other one up with it.
  • The column arithmetic is real CSS in src/styles.css, driven by inline --_mp-* slots. A column is (100% + gutter) * span / columns - gutter, recomputed at four widths, on an element whose column count is declared on its parent — none of which Tailwind can spell, because it finds classes by scanning source text and columns is a number a caller picks at runtime. So the count and the two gutters are declared on the grid and inherited by the items rather than passed through a React context, which is not a shortcut: a media query can change an inherited custom property without React hearing about it, so the count an item lays itself out against is always the one that is on screen. A context would have to re-render the subtree at every window class to say the same thing. Every slot cascades upward, so a span naming only expanded writes one custom property and not five.
  • MPTabs is MD3's primary and secondary, and there is no third. They are not two levels of emphasis, they are two depths — primary tabs are the top level of a screen, secondary tabs divide the content inside one of its panels — and the specification separates them three ways, all of which are here: a 3dp rounded indicator under the label against a 2dp square one across the whole tab, the accent on the chosen label against the plain ink, and the glyph above the label against before it. A variant ladder like the buttons' would be an emphasis axis, and a tab bar has nothing to be emphatic about: it is the map of a screen, not an action on it. The indicator insets itself by exactly the tab's own inline padding, carried as one declaration on the root so the two cannot disagree.
  • There is no orientation on the tabs, and the omission is the specification's. MD3 has no vertical tabs: a column of destinations down the side of a screen is a navigation rail, which is a different component with different behaviour — it switches what the screen is rather than which panel of one is showing, and it is not one tab stop with arrow keys inside it. A tab bar turned on its side would claim the tab contract while looking like the other thing.
  • No speed dial on the floating button. A FAB that fans out into three or four smaller ones is a Material 2 pattern and MD3 dropped it: the actions are unlabelled discs in the corner of the screen, they cover the content the reader was looking at, and a fan of buttons claiming role="menu" without the keyboard contract of one is worse for a keyboard reader than something that never claimed anything. Several actions behind one press belong in an MPMenu — which is a menu, with the roving focus, the typeahead and the escape behaviour that word promises — opened from this button.
  • The floating button is the one component in the library whose corner rides the size ladder, and it is on it because the specification puts it there: corner-medium on the 40dp small button, corner-large on the 56dp one, corner-extra-large on the 96dp large one. Everywhere else a radius is a statement about what kind of object something is rather than a size to taste and stays fixed across the rungs; here the object changes with the rung, which is MD3's own reading of it — a 96dp disc and a 40dp one are two different pieces of furniture, not one at two sizes.
  • MPBottomNavigation is a <nav> of ordinary buttons and links, not a tab list. A tab list owes a keyboard reader one tab stop for the whole set and arrow keys within it, and owes a screen reader a panel per tab; a navigation bar changes what the page is. Claiming the role without the behaviour is worse than never claiming it, because a reader will reach for arrow keys that do nothing. What is claimed instead is aria-current="page", which is the honest statement. Its labels prop drops names from the pixels and never from the document — a glyph on its own has no accessible name at all. MD3 calls this the navigation bar; the export keeps the name the pattern is still known by, and the docs say so.
  • Neither the navigation bar nor the segmented button takes a color. The active indicator is secondary-container because the specification says it is, and for the reason that component already gave: a mark saying where you are is not an accent statement, and primary is what a screen reserves for the action it is about. A navigation bar does nothing.
  • MPPagination and MPRating are not in the specification, and are built out of it anyway. What is Material about the pagination is everything around the decision — cells on the library's control-height ladder, the current page filled with the accent under its own ink, the state layer MD3 uses in place of a hover colour — and the row is pinned to a constant number of slots so that stepping from page 1 to page 2 does not relayout it under the pointer that just pressed one. A gap of exactly one page is filled with that page rather than with an ellipsis wider than the number it replaced. getPageHref turns the numbers into real links so a crawler can follow them, while onPageChange still cancels the navigation for a client-side router; the page being read and a spent stepper stay buttons, because neither is somewhere to go and disabled is not something an <a> can be.
  • The rating is a radio group of real inputs, one per choosable score. A rating is "exactly one of these", so the browser hands over everything a row of <button>s would have to reimplement: one tab stop for the set, the arrow keys within it, aria-checked, a value in a form submission and required validation. Each input is visually hidden under the half of a star it stands for, so a pointer presses a star and a keyboard presses a radio. precision bounds what can be chosen and nothing else — a 4.3 is drawn as four stars and a third at every setting, because an average is not a choice and rounding it would be reporting a different number from the one the component was handed. The fraction is a clip over the star beside it rather than a transform, so nothing is scaled and it fills from the right under RTL with nothing being told to.
  • readOnly on the rating is the one in this library that does not drain the saturation. It is not a control being held still: there are no inputs left at all, only a role="img" carrying the score as a sentence — twenty focusable radios on a page that was only reporting a number is twenty tab stops nobody asked for — and a row of grey stars would say the score itself was unavailable. disabled is the other thing and does drain it, exactly as on every other control.
  • The stars are not amber, and cannot be. MD3 defines four accent families and a fifth colour hardcoded here would be one the token sheet has no name for, a theme has no way to change, and a page could not restyle — the only such colour in the library. A product whose stars must be gold sets --mp-sys-color-tertiary and asks for color="tertiary", which is the answer this library gives everywhere else.
  • Two more message namespaces, pagination and rating, in the same eighteen languages as the pickers, and fillMessage for the four strings that interpolate. Both components are read out and never drawn — what a page cell shows is a digit, what a stepper shows is a chevron, what a star shows is a shape — so every word they say is one only a screen reader hears, which is exactly why they belong in the table rather than in props. A row of nine cells whose caller had to supply nine names is a row of nine English names. The placeholders are named rather than positional, because the order of two numbers in a sentence is not the same in every language: English counts "page 3 of 20" and Turkish counts the total first.
  • MPWindowClass, MPResponsive and MPPosition in the public type vocabulary. The first two are the grid's, and the third arrived under the rule the file states — an axis arrives when a second component needs it, and the floating button and the navigation bar both pin themselves to an edge. It is CSS's own four values, because this is CSS's own question and a second spelling for fixed is a word a caller has to translate.
  • Three glyphs: the two double chevrons and a star. The pager's jump-to-an-end steppers and the rating's mark, named in src/constants/icons.ts like every other glyph the library draws. The star is drawn as an outline and filled by the component — lucide draws it fill="none" and a CSS fill outranks a presentation attribute — so one drawing covers both states rather than the set shipping two.

Changed

  • The label floats. Material's outlined text field starts with its label resting on the control's own line, at the control's own type scale, reading as the placeholder, and lifts it into the notch the moment there is something to make room for. This library drew it in the notch from the first paint — which was a deliberate choice, and the one place its field visibly disagreed with the specification it is built out of. All six notched shells now float it: MPTextField, MPSelect, MPNumberField, MPCombobox, MPColorPicker and the four pickers, each with a floatingLabel prop defaulting to true. floatingLabel={false} is the old drawing, and it is worth having for the one thing the default costs: a field with a label and one without sit at the same height only while the label is up.
  • A startIcon holds the label in the notch, and the placeholder waits its turn. Two things cannot share one spot. A leading glyph is already standing where a resting label would be, so a control that has one keeps its label up — the same rule MUI's InputLabel follows for a start adornment, and the reason a picker's label stays in the notch unless startIcon={null} asks the calendar glyph away. The placeholder is withheld for as long as the label is resting in its place and comes back with focus, which is also when it is of any use: two greyed strings in one box is not a hint. MPColorPicker is the one exception in the other direction — its swatch is the value drawn rather than an affordance, so with no colour in the picker it comes down with the label and returns with it.
  • startIcon={null} on a picker now means no glyph. The default was resolved with ??, which cannot tell "not given" from "given null", so there was no way to ask the calendar or the clock away. It hangs off undefined alone now.

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