tabatkins / css-toggle

Proposal for a CSS Toggle spec
Creative Commons Zero v1.0 Universal
28 stars 1 forks source link

allow connecting more distant elements to the same toggle #46

Open dbaron opened 1 year ago

dbaron commented 1 year ago

There are use cases for connecting more distant elements with the same toggle. For example, typical tab component markup might look something like (in very abbreviated form, with names I just made up):

<tab-view>
  <tab-strip>
    <tab/>
    <tab/>
    <tab/>
  </tab-strip>
  <panel-set>
    <panel/>
    <panel/>
    <panel/>
  </panel-set>
</tab-view>

With this sort of markup, it's hard to model the three tabs using three separate toggles, each with two states, connected into a toggle group. (They could be modeled as a single 3-state toggle, but that has disadvantages, particularly when reordering, adding, or removing tabs.)

It would be desirable to have a way to use a group of 2-state toggles for markup like this.

dbaron commented 1 year ago

cc @bramus

bramus commented 1 year ago

Looking at the component gallery for tabs, many implementations use a structure where the tabs and panels are no siblings of each other indeed.

With the current specced version it’s pretty difficult to implement this. The needed CSS for the example above to work, would look like this:

tab-view {
    toggle-root: tab 3 at 1;
}

tab:nth-child(1) {
    toggle-trigger: tab 1;
}
tab:nth-child(2) {
    toggle-trigger: tab 2;
}
tab:nth-child(3) {
    toggle-trigger: tab 3;
}

tab:nth-child(1):toggle(tab 1),
tab:nth-child(2):toggle(tab 2),
tab:nth-child(3):toggle(tab 3) {
    background-color: lightgray;
}

panel {
    display: none;
}
tab-view:toggle(tab 1) panel:nth-child(1),
tab-view:toggle(tab 2) panel:nth-child(2),
tab-view:toggle(tab 3) panel:nth-child(3) {
    display: block;
}

This does not scale, as it requires extra code for each tab that gets added. It also relies on counting DOM nodes to make it work.

Maybe some extension to toggle-visibility could make this easier, where it would also take the active toggle value into account.

A similar use-case would be a lightbox implementation where thumbnails somewhere in the DOM control the contents of a dialog element, which could be included somewhere else in the DOM.

tabatkins commented 1 year ago

Yeah, the "groups tabs + grouped panels" example absolutely needs something here, and there are additional cases where the standard button might be grouped usefully to see the toggle, but you want the ability to have another button elsewhere that'll also trigger it.

So I've been giving this some thought. Making it work fully ends up being very difficult, but I think I've got an approach that works with minimal user-facing complexity (and hopefully manageable impl complexity).

First, the requirements. We want "grouped tabs" to be as close to equivalent in functionality and ease-of-use as "alternating tabs" (where tabs and the panels they control are next to each other). This means, specifically:

  1. Any number of tabs can be in a group. Adding or removing tabs should require minimal editing; ideally zero changes to the stylesheet, but a small amount of editing in attributes is permissible.
  2. The toggle properties should be as reusable as possible, ideally all using the same generic name for their toggle.
  3. :toggle() selectors must be able to target both the tab and the panel, in a reusable fashion (using a single generic name for the toggle).
  4. The tabs component as a whole must be usable repeatedly on a page without each component having to care about anything outside of itself; any names used are "scoped" to the component rather than global.

My proposal is that we create a new bit of persistent state, the "toggle share". An element can declare that it's sharing toggles under a chosen name, optionally scoped to a group name, and declare zero or more of its own toggles that should be shared to the group. Every element using the same share name (and in the same group) acts as if all the shared toggles among the entire group are present on the element itself, for the purpose of matching :toggle(), toggle-trigger, toggle-visibility, etc.

An element can initialize its toggle sharing via CSS, but like toggles themselves, can't alter or remove them afterwards; you have to use JS to do that, via a new bit of API I'll define.

So here's how I anticipate it looking. First, here's the existing "alternating tabs" structure and style:

<div class=tab-view>
  <div class=tab>foo</div>
  <div class=panel>foo content</div>
  <div class=tab>bar</div>
  <div class=panel>bar content</div>
</div>
<style>
.tab-view {
  toggle-group: --tab;
}
.tab {
  toggle: --tab;
}
.tab:first-of-type {
  toggle: --tab 1 at 1;
  /* first tab is open initially */
}
.panel {
  toggle-visibility: toggle --tab;
}
.tab:toggle(--tab) {
 font-weight: bold;
}
.panel:toggle(--tab) .spinner {
  animation: spin 1s infinite;
}
</style>

Here's how it would look in the "grouped tabs" structure:

<div class=tab-view>
  <div class=tab-list>
    <div class=tab data-share="foo">foo</div>
    <div class=tab data-share="bar">bar</div>
  </div>
  <div class=panel-list>
    <div class=panel data-share="foo">foo content</div>
    <div class=panel data-share="bar">bar content</div>
  </div>
</div>
<style>
.tab-view {
  toggle-group: --tab;
}
.tab {
 toggle: --tab;
 toggle-share: attr(data-share) group --tab, --tab;
 /* share name (can be string or ident, to make attr() usage easier), 
     followed by group name, 
     then comma-separated list of toggle names to share */
}
.tab:first-of-type {
  toggle: --tab 1 at 1;
  /* first tab is open initially */
}
.panel {
  toggle-share: attr(data-share) group --tab;
  /* Share name and group name, but no toggles to add */
  toggle-visibility: toggle --tab;
}
.tab:toggle(--tab) {
 font-weight: bold;
}
.panel:toggle(--tab) .spinner {
  animation: spin 1s infinite;
}
</style>

So the tabs work as normal, each establishing their own 2-state --tab toggle, all grouped under the --tab group so only one is active at a time. But each also shares their --tab toggle under a unique author-chosen name, scoped under the --tab group. The panels then also opt into sharing with the same set of unique names, so they act as if they also contain the corresponding --tab toggle on themselves, so they can use it in toggle-visibility and select it in :toggle().


The related scenario of a standard toggle that wants to be activatable from a random button elsewhere in the DOM works similarly:

<spoiler- id=star-wars>
  <spoiler-warning>star wars spoiler</spoiler-warning>
  <spoiler-content>luke is his own father</spoiler-content>
</spoiler->
...
<button class=toggle data-for=star-wars>Show that Star Wars spoiler</button>

<style>
spoiler- {
  toggle-root: --spoiler;
  toggle-share: attr(id), --spoiler;
}
spoiler-warning {
  toggle-trigger: --spoiler;
}
spoiler-content {
  toggle-visibility: --spoiler;
}
button.toggle {
  toggle-share: attr(data-for);
  toggle-trigger: --spoiler;
}
</style>

Since there's no group in toggle-share, this is a global name (well, global to the tree scope; shadows would encapsulate this), so the button can target it from wherever as long as it matches the name.


Some additional thoughts:

bramus commented 1 year ago

First, here's the existing "alternating tabs" structure and style:

[code block]

Here's how it would look in the "grouped tabs" structure:

[code block]

What I don’t like about this second snippet it that it has a lot of duplication. The .tab toggle already opts into the group because it uses the same name --tab, but in your proposal it’s required to repeat all that info in the toggle-share attribute again:

.tab-view {
  toggle-group: --tab;
}

.tab {
 toggle: --tab;
 toggle-share: attr(data-share) group --tab, --tab;
}

.panel {
  toggle-share: attr(data-share) group --tab;
  toggle-visibility: --tab;
}

What I derive from this example is that each toggle has a binary state (1 or 0) but also wants to represent a certain value in the group when toggled on. Would it be sufficient to only define that value? I’m thinking of a toggle-value property to do this, which would make things much more simpler.

.tab-view {
  toggle-group: --tab;
}

.tab {
 toggle: --tab;
 toggle-value: attr(data-share);
}

.panel {
 toggle-visibility: --tab;
 toggle-value: attr(data-share);
}

The value for the toggle group is that of the active toggle in that group.

Maybe this toggle-value could also become part of the toggle and toggle-visibility shorthands?

.tab-view {
  toggle-group: --tab; /* Establish group */
}

.tab {
 toggle: --tab attr(data-share); /* I am a toggle. When active, the value attr(data-share) becomes the value of the toggle-group I have opted into. */
}

.panel {
 toggle-visibility: --tab attr(data-share); /* I am visible/invisible when a toggle with the value attr(data-share) is active/inactive */
}

The spoiler toggle example would then become this:

<spoiler-wrapper id=star-wars>
  <spoiler-warning>star wars spoiler</spoiler-warning>
  <spoiler-content>luke is his own father</spoiler-content>
</spoiler-wrapper>

<spoiler-wrapper id=star-trek>
  <spoiler-warning>star trek spoiler</spoiler-warning>
  <spoiler-content>Picard is Professor X</spoiler-content>
</spoiler-wrapper>

<button class=toggle data-for=star-wars>Show that Star Wars spoiler</button>
<button class=toggle data-for=star-trek>Show that Star Trek spoiler</button>
spoiler-wrapper {
  toggle-root: --spoiler attr(id); /* Establish toggle root. When the toggle active, the value for the root is attr(id) */
}
spoiler-warning {
  toggle-trigger: --spoiler; /* Activates/deactivates the --spoiler toggle. Because of how scoping works, it’ll find the parent  spoiler-wrapper */
}
spoiler-content {
  toggle-visibility: --spoiler; /* I am visible/invisible when the toggle --spoiler is active/inactive */
}
button.toggle {
  toggle-trigger: --spoiler attr(data-for); /* Activates/deactivates the --spoiler toggle with the value attr(data-for). Because of how wide scoping works, it’ll find the spoiler-wrapper with the relevant toggle-root. */
}

A limitation I see with the above (both your and my versions) is that it’s only possible to automatically respond to a certain toggle value using the toggle-visibility property. You can’t use the automated values in selectors.

.panel:toggle(--tab foo) .spinner {
  animation: spin 1s infinite;
}

One could leverage the attribute that was set, but this breaks copy pasting.

.panel[data-share="foo"]:toggle(--tab) .spinner {
  animation: spin 1s infinite;
}

But maybe this is an edge case, that should not be covered. I mean, if one wants to specially style a specific element they would already need a specific selector for it anyway, without toggles even coming into play.


Since strings are allowed, and counter() can produce incrementing unique strings associated with sibling index, in the tabs example we could do some trickery to eliminate any need for the author to specify unique names

Yes! I like this! In the future sibling-index() could even further simplify the code.

tabatkins commented 1 year ago

What I don’t like about this second snippet it that it has a lot of duplication.

It doesn't need that additional info. If this is the only instance of this element on the page, then you can just do a global share. Scoping to a group is just required if you want to have multiple instances of the same component on the page.

I don't think I can reduce the duplication. The element hosting the toggle knows that the toggle is scoped to some group, but elements that share in it don't (and can't) know this, so they have to specify it manually so they know whether or not to limit their search to a group!

(I'll also challenge that it's "a lot" of duplication, fwiw. It's repeating the name of the group, that's it.)

What I derive from this example is that each toggle has a binary state (1 or 0) but also wants to represent a certain value in the group when toggled on. Would it be sufficient to only define that value? I’m thinking of a toggle-value property to do this, which would make things much more simpler.

I've given a lot of thought to this scenario and don't think it helps.

First, it's possible and meaningful for the toggles in a group to be multi-state. I've seen this in videogame UI, for example - a menu of options, where some options can be cycled thru several variants by clicking multiple times. So assuming each toggle is just binary isn't a reasonable simplification.

Second, this isn't actually a simplification. Rather than N toggles all controlled by a group, you get multiple elements claiming a value of an N-value toggle:

A limitation I see with the above (both your and my versions) is that it’s only possible to automatically respond to a certain toggle value using the toggle-visibility property. You can’t use the automated values in selectors.

No, my proposal specifically allows selectors to work with shared toggles. (I listed this as one of the requirements in the beginning!) That's why toggle-share has to be a new piece of persistent state, so we can update the internal data structures that selector-matching reads from, the same way we do with toggles themselves.

I think anything that doesn't do this is unacceptable, which is why I ended up having to reject exploring a value-based solution.

In the future sibling-index() could even further simplify the code.

Yup, indeed!

dbaron commented 1 year ago

I've read through these examples a few times... and I'm concerned that they just feel more complicated than they should be.

One thing I was thinking might help in simplification (although I'm still not entirely sure how) might be to make the sharing go only in one direction rather than being bidirectional. In other words, one element would share a set of toggles, and another element would use that shared set of toggles.

Something else that might help with simplification would be to only support sharing by sibling index and not support any named sharing.

It's also not clear to me that a separate share name is needed in addition to the toggle name.

Combining these ideas, perhaps this could be handled by a pair of properties that modify only the search algorithm for finding a toggle that is in scope. Only the sharing property (and not the searching property) would need to specify the group to limit the scope of the sharing.

So I think then the "grouped tabs" example style from https://github.com/tabatkins/css-toggle/issues/46#issuecomment-1324304073 could instead look something like this:

<div class=tab-view>
  <div class=tab-list>
    <div class=tab>foo</div>
    <div class=tab>bar</div>
  </div>
  <div class=panel-list>
    <div class=panel>foo content</div>
    <div class=panel>bar content</div>
  </div>
</div>
<style>
.tab-view {
  toggle-group: --tab;
}
.tab {
  toggle: --tab;
  toggle-share: --tab sibling-index group;
  /* toggle name, followed by indexing method, followed by optional group keyword
     to limit scope to group's scope (or maybe that should be the default).  In other 
     words, the share would be found at the element that establishes the group, not
     at this element */
}
.tab:first-of-type {
  toggle: --tab 1 at 1;
  /* first tab is open initially */
}
.panel {
  toggle-find: --tab sibling-index;
  /* When searching for a toggle named --tab, instead of searching prior siblings
     and ancestors, jump to the shared toggle --tab (if in scope), using sibling-index
     indexing method to pick the right one.  The search for a share would use the
     same search rules as the search for a toggle or a group. */
  toggle-visibility: toggle --tab;
}
.tab:toggle(--tab) {
 font-weight: bold;
}
.panel:toggle(--tab) .spinner {
  animation: spin 1s infinite;
}
</style>

The use of sibling-index as a keyword would allow extension to counters later if needed.

tabatkins commented 1 year ago

Oooh, these changes look interesting.

I do like the one-way sharing conceptually; I made it two-way mostly just to reduce the amount of unique new state things we need to track. In your proposal I need to expose both toggleShare and toggleFind on elements. That said, the conceptual model is probably a lot easier for authors to understand this way, so it's likely worthwhile. A toggle-find element just acts as if the shared toggle is visible to it, which is nice and simple.

All the internal complexity is still there, tho - dealing with collisions, etc.

Something else that might help with simplification would be to only support sharing by sibling index and not support any named sharing.

I don't think this would be great. Building in a sibling-index share method is certainly a great idea, as a lot of times it'll be precisely what you want and it'll be way easier to understand and use than my counter() hack, but I think having arbitrary buttons able to find toggles is still a useful ability, and I don't want to force authors into a specific markup structure. If they, for whatever reason, need to put additional elements before/between their tabs or their cards, it shouldn't break the feature and force them back into the weird hacks.

This probably implies that we want the share name to always be a string, syntax-wise, so we have space for built-in keywords like sibling-index. That'll make it stand out from the toggle name in the property value, too, which might be a good thing.

Only the sharing property (and not the searching property) would need to specify the group to limit the scope of the sharing.

I'm not sure how we avoid having the finding element specify the group as well. We need to know whether to do a global search or a limited search; and if limited, what group to limit it by. My intention is that the toggle-group it's scoped to does not have to have the same name; I'm reusing the group for an unrelated purpose to the "only one active at a time" thing it does currently. Again, I could invent a new 'toggle-share-group' to more clearly tie it together, but I think a lot of the time it'll be fine. That said, I could def allow omitting the group name to imply it's the same name as the toggle, which'll be the case most of the time.

(I want to similarly relax the same-name restriction for the core toggle-group use-case, too, and would use the same syntax - a plain group means, as today, that the group has the same name as the toggle, but group <<dashed-ident>> would explicitly name a group.)

tabatkins commented 1 year ago

(Maybe group(<<dashed-ident>>) instead, to more obviously tie it together and imply specialization.)

dbaron commented 1 year ago

I'm not sure how we avoid having the finding element specify the group as well.

I'm suggesting that the finding work just like searching for toggles normally does (looking at ancestors and their previous siblings, all the way up the tree), except that once you encounter an element with toggle-find:

Thus when you reach the element with the toggle-group, assuming it's an ancestor or a prior sibling of an ancestor, you'll find the toggle.

tabatkins commented 1 year ago

I'm not sure I understand. How does the toggle-find annotate the search such that it knows to look only within a particular group? Or does it just look for all shared toggles with that name, then filter based on the sharing group?

dbaron commented 1 year ago

It just looks for all shared toggles with that name, with no filtering except for the index-matching.

tabatkins commented 1 year ago

How does that work with, say, two tab-panel elements both using --tab for their stuff? The second card will be looking for the second label, but it'll find the second label from both panel sets?

tabatkins commented 1 year ago

So they're all global? That's not workable, then - it means you can't have two tab-panels on the same page without inventing a unique toggle name for them and updating all your CSS.

Edit: Ah, this window was open and hadn't shown my latest comment so I ended up just making the same comment again. ^_^

dbaron commented 1 year ago

I think it would find the appropriate one, since it would be a search just like the search for toggle groups. (Even though it's possible that both sets of tab panels would be in scope, if one is a descendant of the other, or if they're siblings of each other and the first one uses wide-scoped groups rather than narrow-scoped groups.)

I'm saying that when you hit toggle-find, you continue searching the same sequence of elements (parent followed by its prior siblings, then its parent followed by ...), and continue to follow the rule that you stop the search when you first encounter a match. You just change what you're searching for from being a "normal" toggle to being a set of toggles shared to a toggle group.

tabatkins commented 1 year ago

I'm not sure I see the connection. Searching for toggle groups involves just looking for an ancestor with a particular toggle-group, then you're done. If you need all the toggles from a group, you can search downwards from the group element. You never directly have to look from one toggle in the group to another toggle in the group.

But here that's exactly what you'd have to do - not an ancestor search, but an all-relatives search, for a toggle that's being shared in a consistent way. The "parent + previous siblings" search won't ever find the shared toggle (except in circumstances where it probably didn't need to be shared in the first place).

dbaron commented 1 year ago

What I'm proposing would be a separate use of toggle groups from the use for grouping. The idea would be that toggle-share attaches the shared toggles to the relevant group, and toggle-find changes the search to search for toggles shared to a group. So the modified search would find the toggles because those toggles have been attached to the group (group element).

(Not sure how much back-and-forth we should have about this particular side-discussion here, or whether we should move it elsewhere, though...)

tabatkins commented 1 year ago

Okay, so you're suggesting that the element holding the toggle-group is aware of the shared toggles underneath it that are scoped to it, so you don't need to guess which toggle-group might be holding a matching shared toggle for you, you'll just see it as you proceed up the scopes.

I suppose that works. It does still mean that you can't have a global and a grouped shared toggle with the same name, if you want something that happens to be inside the toggle-group to refer to the global one. But maybe that's a sufficiently rare case that it's justified by the reduction in verbosity from the toggle-find not needing to say it's group-scoped as well?

dbaron commented 1 year ago

(One side note: this sibling-index idea is probably a good bit simpler to implement than the idea in w3c/csswg-drafts#4559, because I think that idea requires that the sibling index be numerically calculated into the computed value, which has a bunch of interactions with various optimizations that exist in style engines today, whereas this proposal could allow sibling-index to be part of the computed value representation as a keyword.)

dbaron commented 1 year ago

I realized in a conversation today that one disadvantage of doing this is that it enables patterns that work badly when the CSS is removed. In other words, representing tabs with markup that has heading-section-heading-section-etc. still produces content that is likely to function when the CSS is removed and the markup is presented linearly. However, representing tabs with markup as in https://github.com/tabatkins/css-toggle/issues/46#issue-1441189478 doesn't work very well when the CSS is removed.

On the other hand, the heading-section-heading-section-etc. markup is harder to style because it can require hacks to reorder.

In other words, I'm not happy about either option here. :-/

tabatkins commented 1 year ago

Yeah, making header/section/header/section work properly requires the work in flowing disparate elements to the same grid cell that should now be possible in GridNG in Chrome, and hopefully in other browsers too. Then tabs can be done with a two-element grid, with all the tabs visible in the first cell and all the panels overlapping in the second cell.