Open rjdellecese opened 3 years ago
Nice. Thank you for writing this up!
Stacking shadows makes sense. I think I initially avoided that because each shadow is "expnsive", but in retrospect I think I was wrong 😅
I believe I already added a shadows : List Shadow -> Attribute msg
to elm-ui 2.0 If not, I will likely add it.
So, here are the main things I think of with batched attributes.
In order to figure out whats going on you have to look at every rule(in the worst case) that applies to an element.
Deciding which properties to group together is actually a surprisingly hard problem!
Here's a question: Are there attributes that, when grouped, would lead to subtle, rough situations?
Well, let's say I've chosen to create two groups
one =
[ Background.color blue
, Font.size 28
]
two =
[ Background.color red
, width (px 500)
]
Sure, it's obvious when we have these two literal definitions in front of us, but less obvious in a real code base.
Comparing things to Cmd
is kinda tough because Cmd
s can't interfere with each other. The situation we have in elm-ui would be analogous to having behavior where adding another Http.get
prevents a previous Http.get
from firing! Oof
However! I recognize there's a painpoint here!
One approach that I've been most interested in is in having constructors for specific situations where we get that desire for binding two properties together.
So, one example of this would be allowing a constructor to take multiple shadows.
A few others could be:
Element.palette - Setting background color, border color, and font color in one go. This is nice because we now know that our font color will have the right contrast with the background, and that the border will be appropriate for the background as well.
Font styling. It's really nice to have a concrete, finite list of typography combinations (font-family, size, weight, style...maybe color) in your Ui.elm.
The harder part is trying to make these combinations up on the fly. So, maybe the best practice here is being able to set these properties all in one go as a Font.with : { weight : Int, size, family...}
.
Then you can just say Ui.font.small
and you get the full thing.
The nice part about these groupings is that they push you towards using your design system properly.
So! Are there other grouping helpers we could create? There probably aren't a ton.
Fantastic points! I'm going to try to state as concisely as I can all of the issues at play here.
Font.color
is one example. A font may have exactly one color, and no more. So applying Font.color
multiple times to the same element is always a non-sensical operation.Element.shadow
is one example. An element may have an arbitrary number of shadows, so applying multiple shadows will always have an effect (unless of course you apply the exact same shadow multiple times).styleOne
) is applied to the parent element, the number of "correct" values of the other (styleTwo
) in the child element are constrained by the value of the first (styleOne
). But, it can be difficult sometimes to trace what the value of styleOne
is, because it's applied out of scope of the application of styleTwo
.These raise a few questions/bring a few ideas to mind:
Font.with
, Element.palette
or shadows
batching examples).elm-ui
be easier to use if styles were not inherited by default?[Font.color red, Font.color blue]
is non-sensical, is there some way this kind of specification could be made invalid, or unlikely to occur?Element.Attributes.none
and Element.Attributes.batch
functions would still be useful under certain circumstances, although I agree that there are some other overlapping problems that they might be used to solve which could be better addressed by other means.Nicely said!
4./2. Only font attributes are inherited I believe, which is kinda necessary or you'd have to set font size/color on every node. 🤔 mostly the sort of indirection that I find confusing is when you have multiple lists of styles and concat them for one element.
Element
was el : AttributeRecord -> List (Element msg)
, but you could still update the record. And it would be a more awkward API.The standard behavior is to always have a property overwrite a previously defined property. For shadow
type stuff, maybe this means it makes sense to remove Border.shadow
and only have a shadow constructor that takes a List Shadow
. This would remove the ambiguity of stacking as there would be one obvious way to define multiple shadows.
Attr.none
may be easier than Attr.batch
, maybe you have an event handler that you only sometimes want to attach.
- Yeah, it's tough or maybe impossible to enforce. Maybe if
Element
wasel : AttributeRecord -> List (Element msg)
, but you could still update the record. And it would be a more awkward API.
Yeah, good points.
The standard behavior is to always have a property overwrite a previously defined property. For
shadow
type stuff, maybe this means it makes sense to removeBorder.shadow
and only have a shadow constructor that takes aList Shadow
. This would remove the ambiguity of stacking as there would be one obvious way to define multiple shadows.
That is definitely an interesting idea! Seems worth exploring, IMO.
- If we can find those other situations, would love to talk about them.
Attr.none
may be easier thanAttr.batch
, maybe you have an event handler that you only sometimes want to attach.
I looked through my codebase again for better evidence in light of the good points you've made in this thread, and found what I think are the remaining use cases for Attr.none
and Attr.batch
functions:
When I have a set of Html.Attribute msg
s (via Element.htmlAttribute
) that I want to apply all at once (or not at all), such as this one:
noSelect : List (E.Attribute msg)
noSelect =
[ E.htmlAttribute (Html.Attributes.style "-moz-user-select" "none")
, E.htmlAttribute (Html.Attributes.style "-ms-user-select" "none")
, E.htmlAttribute (Html.Attributes.style "-webkit-user-select" "none")
, E.htmlAttribute (Html.Attributes.style "user-select" "none")
]
This would also apply for groups of Html.Attributes.property
s and Html.Attributes.attribute
s that you might have, as well (I have some of each of those, too).
I recently read this blog post about the "phantom builder pattern", and realized that it could probably be employed to prevent non-sensical scenarios like the aforementioned "include two font colors in the same attributes list", e.g. [Font.color red, Font.color blue]
. Roughly, this "phantom builder pattern" would allow you to track the number of times that a function has been applied to a term, at the type-level. I wonder what that would look like?
Here's a hypothetical API:
el : Attributes attrs -> Element msg
emptyAttrs : Attributes Attrs
{-| Likely there is a better name for this record than `Attrs`.
-}
type alias Attrs =
{ fontColor : InheritAttribute
, shadows : UnsetAttribute
-- and more
}
{-| The attribute was set explicitly by the user on this node.
-}
type ExplicitAttribute =
ExplicitAttribute
{-| The attribute is inherited from its parent.
-}
type InheritAttribute =
InheritAttribute
{-| The attribute does not apply to the current node.
-}
type UnsetAttribute =
UnsetAttribute
fontColor :
FontColor
-> Attributes { attrs | fontColor : InheritAttribute }
-> Attributes { attrs | fontColor : ExplicitAttribute }
shadows :
Shadows
-> Attributes { attrs | shadows : UnsetAttribute }
-> Attributes { attrs | shadows : ExplicitAttribute }
And that API in use:
el
(emptyAttrs
|> fontColor red
|> shadows myShadows
)
(text "Hello world!")
One downside to this approach is that Attributes
's type parameter needs to be fully-specified, or else the whole type signature must be omitted. In other words, this definition works, with the given type signature or without any type signature at all:
myAttrs : Attributes { fontColor : ExplicitAttribute, shadows : ExplicitAttribute }
myAttrs =
emptyAttrs
|> fontColor red
|> shadows myShadows
But this one doesn't:
testAttrs : Attributes attrs
testAttrs =
emptyAttrs
|> fontColor red
|> shadows myShadows
When the value of attrs
is very large, as it would be in an real implementation in elm-ui
, it would probably be preferred to just omit the type signature of a function with type Attributes attrs
(but the monomorphic version—where attrs
is a single, concrete type). But that's atypical in Elm code, and might be somewhat confusing to users.
Here's an Ellie with all of the above code stubbed out: https://ellie-app.com/ftHFPcQynt4a1
Anyways, I'm not sure whether an API like this would make sense for elm-ui
, but I thought it would be neat to sketch out the idea, and in general thought it was neat that it's technically possible (and does offer some real benefit to the user)!
The problem
Let's say that I have the following code:
And I use it in some places, e.g.
However, I then decide that I want make my
dropShadow
to feel like it has a little more depth to it, by adding anotherElement.Border.shadow
. Now its type signature must change.Which means that the places where it was invoked must change, too. But how? We've got a few different options, which is already a bad sign.
Option 1
Option 2
Now let's say that I want to add another new style to this
dropdown
element, which looks like this:How should I accomplish this?
In the case where I previously went with Option 1, I might do this:
And in the case where I had gone with Option 2, I might do this:
Ok, that works. Now let's say that I actually decide that I want
square
to only dictate the width of an element (my metaphors are breaking down here, apologies!). I could make one of two changes to accomplish this. Eitheror
The first of these is probably the most obvious one, which most users would prefer to make, but would then require further tweaking to all of the places where
square
is called. The second doesn't change the type signature and would thus invoke less churn at the call sites, but then raises questions like, "why not make all of my composableElement.Attribute msg
functions wrapped in aList
?" And also, to those less in-the-know, "why is this thing wrapped in aList
?" Which again brings us to this problem of there being multiple ways to accomplish the same thing—always something to avoid, and something which we are usually especially good at avoiding in Elm!One final example of where problems around the composition of
Element.Attribute msg
s arise is in cases where the expression might not even contain an attribute at all. Imagine that my originalsquare
function accepts aBool
which determines whether or not the element involved ought to be a square. How should I implement this? I again have multiple options.or
Which is better? More correct? If this were an
Element msg
I might useElement.none
, but I have no such option here.Ok, but what's the problem?
The problem here is that I'd like to treat
Element.Attribute msg
as a monoid—which is mostly just a fancy way of saying that I'd like to be able to reason about composing them independently ofList
s. In all of the examples given above, I'd like it if the type signatures of my helper functions never had to change, regardless of the number of attributes that I add or remove as I develop my code. I want to be able to think about something likedropShadow
as anElement.Attribute msg
, and not have to be bothered in the type signature with whether its implementation involves just oneElement.Border.shadow
, or many (or even, potentially, none).The solution
Thankfully, solving this problem is fairly simple and straightforward, and has precedence in Elm already—all we need are two new functions. The precedence for
Cmd msg
takes the shape ofCmd.none
andCmd.batch
—same forSub msg
(Sub.none
andSub.batch
). The particular names and name-spacing aren't that important to me, they just need to have the following type signatures:If functions like these were available in
elm-ui
, there would be obvious best answers to the questions of "how do I implement this change?" in all of the previous examples—they could all be re-written to returnElement.Attribute msg
, exclusively.