RaspberryPiFoundation / sauce-design-system

https://sauce.raspberrypi.org
1 stars 1 forks source link

Adaptive Components #36

Closed Jonic closed 9 months ago

Jonic commented 4 years ago

The new "Teach" page has cards that start vertical, switch to horizontal, at a breakpoint as the screen grows, and then back to vertical at a larger one. Like this:

V -> H -> H -> V -> V

We could do with solving this problem in such a way that we don't have to continually override styles to flip them back and forth as the page grows. We'll no doubt encounter this issue in future components too.

The Cube CSS methodology has a concept of "Exceptions" to denote component state (https://piccalil.li/cube-css/exception/#main-content). I don't think that's the right word for it, and think that "State" is fine, but the concept mirrors what I was thinking we'd do with our components. Any major variation in a component's own layout would be treated as "State" rather than having media queries in each selector.

All of the styles that are responsible for enacting a state should be grouped together under a data attribute, and treated like a sub-component.

So instead of this:

.sc-rp-card {
  display: flex;
  flex-direction: column;
  flex-grow: 1;
  height: 100%;

  @include device.min-width(600px) {
    flex-direction: row;
    height: auto;
  }

  @include device.min-width(1000px) {
    flex-direction: column;
    height: 100%;
  }
}

We'll end up with something like this:

.sc-rp-card {
  --flex-direction: column;
  --height: 100%;

  display: flex;
  flex-direction: var(--flex-direction);
  flex-grow: 1;
  height: var(--height);
}

.sc-rp-card[sauce-state-vertical] {
  --flex-direction: row;
  --height: auto; 
}

The main advantage being that we have an easily identifiable way to alter state through the HTML and JavaScript.

Obviously this only solves part of the problem. In the absence of a Finite State Machine in CSS, we still need a way to apply this state based on the screen width. This is where progressive enhancement comes into play.

The baseline experience - no JavaScript

The cards stay in the state defined in the markup. They do not change state as the browser grows. We can stop them getting too big, and use layout to control certain aspects of where they end up. The most important thing for the baeline experience is that things are readable and accessible. The whole point of progressive enhancement is that your "baseline" experience may not look the same as your "target" experience.

The target experience - JavaScript is enabled

The cards' state is controlled with JS at certain browser sizes, which will set the state data attribute accordingly. We may be able to supply the JS to achieve this with the components, or provide a small utility that will read the state change intentions from the markup, which can be reused across any of our applications.

It might also be worth bringing back the concept of a js/no-js classname on the <html> element, which will help us further solve layout issues where JavaScript isn't present.

That's my initial thinking, anyway. Any thoughts?

iamkeir commented 4 years ago

@Jonic I like it, it makes sense... but could you help me understand why you want to avoid media queries for this?

Will be interesting to discuss the responsive JS state handler.

Jonic commented 4 years ago

@iamkeir If we could apply the data attr to control the state with a media query, then that could work. However that would still need to be controlled at the application level. The components shouldn't really know anything about the layout or the browser width. They should just display what they're told to. The application/page/layout should control how things are displayed in specific contexts.

Controlling the state with a flag, rather than media queries, gives us the absolute most flexibility here. It provides fewer places for implementation errors to hide, and enables true progressive enhancement.

It also helps us head down the path where the width of the page isn't the main deciding factor in how individual components are displayed. Until we have container queries then this is looking like our best bet so far!

iamkeir commented 4 years ago

@Jonic agreed and understood, progressively enhance until we have our holy grail container queries. It has got my brain wondering whether CSS custom props and some sneaky coding could help... will ponder.

Jonic commented 4 years ago

@iamkeir As for the JavaScript state switch thing. We can use the matchMedia API and see if we can figure out some light config in the component definition that will allow us to standardise this.

<div class="card" data-sauce-state="vertical-narrow" data-sauce-responsive-states="vertical@500px, horizontal@800px, horizontal-wide@1200px">

Something like that, maybe. We can look for that value, parse out the values, feed them into matchMedia, and swap the data-sauce-state value as necessary.

Custom props will only do half the job, but for the actual state switching I'd like to consider their use, yeah. Haven't done much with them yet, but this seems like the perfect use for them. I can see Sass vars controlling things like tokens, and populating the values for custom props, but the actual state switching itself will be powered by the custom props.

iamkeir commented 4 years ago

@Jonic yep I follow. Is there value in actually investigating the container query approach? As in, rather than using @screen-width we set state based on @available-space. I can't currently visualise how that would work yet though...

Jonic commented 4 years ago

Looking at it now it's in a HTML example that does look rather verbose, so I'm sure there's a neater solution for how we specify the state swapping, but that's just an idea of the kind of thing I'm thinking.

It would be nice if we could handle it kinda like the <picture> or <video> elements, with their various <source> children.

<div class="card" data-sauce-state="vertical-narrow">
  <state media="(min-width: 1200px)" value="horizontal-wide" />
  <state media="(min-width: 800px)" value="horizontal" />
  <state media="(min-width: 500px)" value="vertical" />
   ... card stuff here ...
</div>

That would enable us to keep everything nice and generic so we can use it on any component we like, and it also removes the association between state and page-width. We could add any media query we like in the media attribute, which will give us some extra flexibility in how we approach true "responsive" design.

This actually kinda feels like rolling our own version of container queries, except without a polyfill or anything. It makes it super obvious what's going on, so if container queries ever do become a thing we can easily swap these out for real container queries.

The problem with implementing any kind of polyfill now is that there is no spec to follow. We're just as good defining our own system. We might even be able to use our findings to contribute back to the discussion around the spec definition itself!

glenpike commented 4 years ago

Naive question, but could we not use 'between' media queries if the issue is having multiple media queries for different sizes?

Injecting variables to control JavaScript or CSS into the DOM seems very much an Angularisation - previous experience with this, dev's found it very hard to maintain / debug issues because they had multiple places to look for things that should have been in JS (in this case CSS)

Using a state like variable [new-state] also implies that this would appear in multiple places in the CSS, potentially with variations for components that change state at different screen-sizes, increasing complexity somewhere away from the CSS again.

Performance 🤷 - any issues with this happening on load, after CSS has rendered, thus causing 'jumping' content?

Jonic commented 4 years ago

@glenpike Not naive at all. Don't say such things ;)

So here's the situation:

Screenshot 2020-09-04 at 13 04 48

The cards start as vertical, then we get to a screen width where they swap to horizontal, so we override the styles. But then we get wider still, and they swap BACK. So we have to override the overrides. Which is doable, but it's bad for a few reasons:

We're not REALLY injecting variables. We're telling the component which state to use. The way the thing LOOKS is still controlled by CSS. We could make a case that "state" is how the component BEHAVES, which is for JavaScript.

Using the picture or video element example I just mentioned, there's already a precedent for this kind of thing in the HTML spec. Swapping out an image or a video is slightly different to altering the page layout, but it's for the same purpose: controlling how the page itself reacts to differing media queries. This also make use of the matchMedia API, which is specifically designed to enable this kind of thing.

Using a state like variable [new-state] also implies that this would appear in multiple places in the CSS

Not sure I get what you mean with this thing. If you could elaborate that would be ace :)

As for performance and 'jumping' content. You're dead right that we need to consider that. Jumping content just occurs as a page renders anyway, so we might be able to get away with it, but if it causes any significant jank then yeah we should think it over some more. The JS I'm thinking should be small enough that we could inline it at the bottom of the document, and it'll be platform-agnositic, so we should be able to keep things snappy. This is always a valid concern when approaching any progressive enhancement, so we'll definitely have to make sure to not hurt the user experience!

I know we're trying to move away from relying too heavily on JavaScript, but that's not to say that we can't use some JavaScript :)

I think my next Innovation Day plans are sorted, anyway!

Jonic commented 4 years ago

Further to @iamkeir 's suggestion for using CSS Custom Properties for handling the state change in the styles, I'm revising the example above to this:

.sc-rp-card {
  --flex-direction: column;
  --height: 100%;

  display: flex;
  flex-direction: var(--flex-direction);
  flex-grow: 1;
  height: var(--height);
}

.sc-rp-card[sauce-state-vertical] {
  --flex-direction: row;
  --height: auto; 
}

This also corrects a nesting issue present in the example, which would have resulted in a selector like [state] .class-name, which of course doesn't work because the class and state are at the same level in the DOM.

Jonic commented 4 years ago

I've put together a demo of what this all looks like over on my personal site: https://state-stuff.100yen.co.uk/

The repo for the demo is here: https://github.com/Jonic/state-stuff

Jonic commented 4 years ago

It's been brought to my attention that I haven't made one crucial point clear enough, when considering this example:

<div class="card" data-sauce-state="vertical-narrow">
  <state media="(min-width: 1200px)" value="horizontal-wide" />
  <state media="(min-width: 800px)" value="horizontal" />
  <state media="(min-width: 500px)" value="vertical" />
   ... card stuff here ...
</div>

The state elements do not come included with the component. The states do, and are defined as usual in the component styles (which will of course have a "default"), but states should be determined at the application level, within a specific layout context.

Jonic commented 4 years ago

Here's a little sketch that illustrates a scenario where having different state behaviors would be beneficial.

IMG_0017

glenpike commented 4 years ago

"Using a state like variable [new-state] also implies that this would appear in multiple places in the CSS"

Not sure I get what you mean with this thing. If you could elaborate that would be ace :)

Would the usage be restricted to a single component, e.g. .sc-rp-card[sauce-state-vertical], or is it possible we would start adding them to more components / more variations? Would this make things: a) more complicated / harder to maintain b) less performant if many things are triggered on a layout change - e.g. screen orientation?

Jonic commented 4 years ago

@glenpike Each component would have its own set of states, which are defined in the styles, and documented in the pattern library. The usage is flexible insofar that you can define any combination of states on any component, so long as the component understands the state you're asking it to use.

As for how complicated this makes things, that depends on the layout itself. The states won't make the styles any more complicated. It's a utility to apply at the application level in order to achieve a specific responsive layout. We'll obviously have to see how things turn out in a more fleshed out example, but I'm confident that this will make it easier to:

I actually think this will make it easier to maintain both the component styles and the implementation of the components, because we don't have implementation stuff mixed in with the component styles. This will give us much more confidence in updating the components without worrying that it's broken the implementation on a site we haven't looked at in a year.

As for screen rotation, that's something we won't really get a fully handle on until we've built some fuller examples. Looking on my iPad it appears that the matchMedia events occur the instant my device registers the orientation change, so the state update occurs at the very start of the screen rotation animation. It appears that this is the case for regular CSS breakpoints too; the layout is repainted before the screen rotation starts.

This means that the screen rotation might help mask any serious jank if its present, so we might be able to get away with it, although I would of course like to avoid ANY jank where possible.

As for page performance, we'd also need to see how a bigger layout is impacted in terms of First Contentful Paint, First Meaningful Paint, and Time to Interactive. I'm hoping that since we're updating very little of the DOM and applying styles that the browser should already have, that the performance impact will be minimal. We'll need to make sure we measure it properly, of course.

Jonic commented 4 years ago

I also should mention that it's totally possible and expected to add just a data-sauce-state="whatevs" attribute to apply a state without any responsive stuff involved. I can almost forsee a future where the "theme" styles for the components are in fact simply states of a generic component. We'll have to see how that pans out when we're using Sauce for more stuff.