Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Proposal: beforematch event and hidden=until-found attribute #6040

Closed
josepharhar opened this issue Oct 8, 2020 · 31 comments
Closed

Proposal: beforematch event and hidden=until-found attribute #6040

josepharhar opened this issue Oct 8, 2020 · 31 comments
Labels
addition/proposal New features or enhancements needs implementer interest Moving the issue forward requires implementers to express interest

Comments

@josepharhar
Copy link
Contributor

josepharhar commented Oct 8, 2020

I would like to propose a new DOM event called “beforematch” (TAG review).

The beforematch event would be fired in response to find-in-page and ScrollToTextFragment on content which was hidden by a new CSS property, content-visibility: hidden-matchable.

When find-in-page or ScrollToTextFragment tries to scroll to a match which is within the hidden-matchable subtree, the beforematch event will fire on the element with the hidden-matchable style before the browser scrolls, allowing the page to change the style to reveal the content so the browser can scroll to it and show it to the user.

An example usage of this feature would be to search the content of collapsed sections of mobile wikipedia, which are not currently searchable.

Sketch of edits to spec

The find-in-page algorithm will be modified as shown in the github repo - the step where the active match is “scrolled into view” will be replaced with:

  1. At step 12 of the next update-the-rendering opportunity:
    1. If the DOM range representing the active match has been collapsed, start the search over again starting at this collapsed range.
    2. Let “matchable ancestor” be the nearest flat-tree ancestor element of the beginning of the DOM range representing the active match which has the content-visibility: hidden-matchable property.
    3. If the beforematch event has been disabled or there is no matchable ancestor, call scrollIntoView() on the active match and end the algorithm.
    4. Fire the beforematch event on the matchable ancestor.
    5. Cause a need for another update-the-rendering.
  2. At step 12 of the subsequent update-the-rendering opportunity:
    1. Disable the beforematch event for the remaining lifetime of the document and start the search over again starting at the end of the active match DOM range if any of the following conditions are true (explained in the privacy section):
      1. The DOM range representing the active match has been collapsed.
      2. The active match has the content-visibility: hidden-matchable or content-visibility: hidden properties.
      3. The active match has the display: none property.
      4. The active match’s visibility property is not visible.
    2. Call scrollIntoView() on the active match.

Security/privacy impact

Leaking information to the page about what the user is searching for with find-in-page is a big concern with this feature, but there is already some information which find-in-page necessarily exposes to the page. To limit the information exposed by this feature, we have designed two mitigations:

  1. The beforematch event will only be fired on text hidden with the content-visibility: hidden-matchable CSS property. This prevents the page from attaching beforematch event listeners to all of the visible content in the page and snooping on what the user is searching for.
  2. If the page fails to reveal the active match when the browser tries to scroll to the active match after firing the beforematch event, the beforematch event will be disabled for the remaining lifetime of the document. By “reveal,” I mean that the active match fulfills all of these requirements:
    1. The DOM range of the target match is not collapsed (meaning that the text was removed not from the dom).
    2. The content-visibility CSS property is not hidden or hidden-matchable.
    3. Thedisplay CSS property is not display: none.
    4. The visibility CSS property is visibility: visible.

Open questions

Right now, I am proposing that the beforematch event is only fired on text with the content-visibility: hidden-matchable property in the ancestor chain. However, we have some other options:

  1. Stick with firing beforematch on content-visibility: hidden-matchable.
    Cons:
    1. Implementing browsers might also need to implement some of the content-visibility: hidden properties, depending on how the spec is phrased.
  2. Fire beforematch on content-visibility: hidden-matchable and visibility: hidden.
    Cons:
    1. There is already a lot of visibility: hidden content out there on the web, and if we started firing beforematch on all of it, the page would be required to reveal it or else it would be locked out of using beforematch as described in mitigation 2 above. This means that many pages will get locked out of beforematch all the time by default, which would not be good.
  3. Fire beforematch on content-visibility: hidden and visibility: hidden if they also have another new CSS property, let’s call it searchable: true.
    Cons:
    1. Requires two CSS properties to get the desired functionality. In the visibility: hidden case, you’d also have to add even more CSS to get it to stop taking up space in the layout if you want it to function like content-visibility: hidden.
    2. It would become a bit more complicated and ambiguous to choose an element to fire the beforematch event on. Do we fire it on the element with the hidden property, the element with the searchable property, or the closest one?
@domenic
Copy link
Member

domenic commented Oct 8, 2020

FWIW, I'm very excited and supportive of this, and happy to help.

Sketch of edits to spec

This looks pretty nice. You'll need to do a bit of extra work to track what iteration of "update-the-rendering" you're in; probably some kind of per-event-loop state variable with three values? Also, instead of calling scrollIntoView() directly, you'll want to call https://drafts.csswg.org/cssom-view/#scroll-an-element-into-view

@tomayac
Copy link

tomayac commented Oct 12, 2020

Talking as the developer of the Link to Text Fragment extension (60K+ users), it's a top feature request of users to link to expandable items (e.g., accordions), so adding a platform capability that would allow sites to detect when and react to when a match is about to happen is highly desirable.

@josepharhar
Copy link
Contributor Author

FWIW, I'm very excited and supportive of this, and happy to help.

<3

You'll need to do a bit of extra work to track what iteration of "update-the-rendering" you're in; probably some kind of per-event-loop state variable with three values?

The way I implemented this was with a single cancellable callback variable for both of the two spots where we wait for the next update-the-rendering. This way, if another find-in-page request comes in, we will cancel the callback by replacing it with a new callback. The callback itself has the next method which will run (step 1 vs step 2) as well as a bunch of state to call the methods with, including the DOM range to scroll to as well as a flag to say whether the match originally had content-visibility:hidden-matchable in an ancestor when we first found it.

I see now that the spec steps I listed don't really cover this. What is a per-event-loop state variable? Are there any examples of this or anything else that sounds similar to the behavior I described?

Also, instead of calling scrollIntoView() directly, you'll want to call https://drafts.csswg.org/cssom-view/#scroll-an-element-into-view

Thanks! Would we be "calling" or "running" this step?

Talking as the developer of the Link to Text Fragment extension (60K+ users), it's a top feature request of users to link to expandable items (e.g., accordions), so adding a platform capability that would allow sites to detect when and react to when a match is about to happen is highly desirable.

Awesome! Do you have any links to these feature requests?

@alice
Copy link
Contributor

alice commented Oct 15, 2020

Coming here from the TAG review.

We were curious about what the timing constraints are on developers for the requirement to show content with a match in order to continue having the feature enabled on the page.

The spec edits above make it look like every single beforematch event must result in content being shown, even if another event happens very quickly afterwards (say, because the user was typing a word or phrase and the find in page algorithm eagerly matched a partial word before they finished typing).

Will there be any throttling on the event being fired, to avoid jittering as find in page matches "L", then "Lo", then, "Lor", etc? Or have I misunderstood something?

@domenic
Copy link
Member

domenic commented Oct 15, 2020

What is a per-event-loop state variable?

By this I meant declaring something in the spec like "Each event loop has an X, initially 0." But perhaps sticking closer to your implementation structure might be good. In which case it would be something like "Each event loop has a beforematch steps, which is null or a series of steps" plus more "Each event loop has..." for the other state you mention.

A similar example where the spec sets steps and later runs then would be https://html.spec.whatwg.org/#lazy-load-resumption-steps .

Thanks! Would we be "calling" or "running" this step?

We use them interchangeably, but I think we'd usually write "running" in spec prose.

@josepharhar
Copy link
Contributor Author

We were curious about what the timing constraints are on developers for the requirement to show content with a match in order to continue having the feature enabled on the page.

The spec edits above make it look like every single beforematch event must result in content being shown, even if another event happens very quickly afterwards (say, because the user was typing a word or phrase and the find in page algorithm eagerly matched a partial word before they finished typing).

Yes, every beforematch event handler must reveal the content in this case. With regards to timing, this has been an issue since before the privacy mitigations due to the fact that we still have to choose when to make the browser scroll. We added an async step (waiting for another update-the-rendering) in response to early developer feedback since their beforematch handler didn't synchronously reveal the content.

Will there be any throttling on the event being fired, to avoid jittering as find in page matches "L", then "Lo", then, "Lor", etc? Or have I misunderstood something?

If the "L", "Lo", and "Lor" are all part of the same text then the beforematch event will only be fired on the first "L". If they are all parts of separate text, then a beforematch event would be fired on each one and each one would need to be revealed.
We don't currently have any plans to throttle the event, do you have an idea of how this could work? Could you elaborate?

@alice
Copy link
Contributor

alice commented Oct 16, 2020

If the "L", "Lo", and "Lor" are all part of the same text then the beforematch event will only be fired on the first "L".

That sounds less than ideal, then, if the user is typing "Lorem ipsum" but they only get a match on "L"? Have I missed something?

We don't currently have any plans to throttle the event, do you have an idea of how this could work? Could you elaborate?

Basically wait for XX msec before firing the event, and cancel it if the query text has changed in the meantime. You could potentially combine that with a longer timeout for shorter text (because "L" will have more matches/noise than "Lo", etc.)

@josepharhar
Copy link
Contributor Author

That sounds less than ideal, then, if the user is typing "Lorem ipsum" but they only get a match on "L"? Have I missed something?

Yeah so I suppose that if the user is searching for something, but in the process of typing in their query they get an active match for some text which isn't the final query they want, and that active match is hidden and gets revealed, that would be less than ideal:

<div style="content-visibility: hidden-matchable">
  Lots of kittens
</div>
<div>
  Lorem ipsum
</div>

Is this the case you're talking about?

Basically wait for XX msec before firing the event, and cancel it if the query text has changed in the meantime. You could potentially combine that with a longer timeout for shorter text (because "L" will have more matches/noise than "Lo", etc.)

This sounds similar to @kenchris's comment from the TAG review:

As far as I understand, the user has to reveal the content immediately when a match is found, which might result in content shifting around. Especially I might be typing "icecream" really fast, making a section with the text "ice" show and then unmatch because it didn't contain "icecream". Should there be a bit if grace period, like find-in-page text only triggering an event when the user has made a small pause, like say 50-100ms?

Safari does this for all usage of find-in-page, and the delay is pretty visible and I could imagine that some users might not like this behavior for find-in-page since it may seem less responsive.
If we added a delay just for the hidden-matchable/beforematch case and made it small enough that there isn't a negative impact on user experience, then I feel pretty open to the idea. I can see how having sections open while you're still typing isn't good UX. 👍

@alice
Copy link
Contributor

alice commented Oct 18, 2020

Is this the case you're talking about?

Yes, exactly that. Or even if "Lorem Ipsum" is also in a different hidden-matchable subtree - then you have "Lots of kittens" showing and potentially hiding, and then "Lorem ipsum" showing... a lot of rapid changes back to back.

You could also explore having a grace period before locking the page out of the beforematch functionality - i.e. the first beforematch event starts a timer, and you have to show some content which had a match before the timer elapses (maybe with a "last chance" callback?). That would be a much more complicated API, though.

Seems like it would be sensible to do some experiments to figure out what the best user experience is, in any case.

@alice
Copy link
Contributor

alice commented Oct 18, 2020

Another option might be to do something similar to what the Scroll To Text Fragment API did, and only match on word boundaries. Was that explored in the security/privacy review?

@vmpstr
Copy link
Member

vmpstr commented Oct 20, 2020

showing and potentially hiding, and then "Lorem ipsum" showing... a lot of rapid changes back to back.

This seems like the current behavior of find-in-page (more or less, in Chromium at least): each character typed will instantly find the next match that matches the whole query and scroll to it. The scroll can also be observed by script and it can react in any way it wants to after the scroll happens. The added feature with this proposal is that there's an interleaved event which is able to modify style/dom before the scroll occurs.

Another option might be to do something similar to what the Scroll To Text Fragment API did, and only match on word boundaries. Was that explored in the security/privacy review?

If I understand this correctly, then it seems like it would break existing find-in-page feature. Currently the match happens if any substring of the word (or multiple words) matches the query. If we only update the match on word boundaries, it seems like it would break use-cases. If we only fire beforematch on word boundaries, it seems like it would be hard to explain and also limit use-cases

If the "L", "Lo", and "Lor" are all part of the same text then the beforematch event will only be fired on the first "L".

That sounds less than ideal, then, if the user is typing "Lorem ipsum" but they only get a match on "L"? Have I missed something?

I think the distinction here is that beforematch fires on hidden-matchable content to allow script to make the match visible; once visible, the find-in-page finds the text as before without the need for beforematch.

I'd just like to clarify my understanding of what happens with a specific example. Let me know if I'm correct in understanding this. Consider:

<div class=spacer></div>
<div id=foo style="content-visibility: hidden-matchable">Lorem Ipsum</div>
<div class=spacer></div>
<div>Lord of the Rings</div>
<script>
foo.addEventHandler("beforematch", () => { foo.style = "" });
</script>

Consider the user taking the following steps:

  1. Open find-in-page dialog
  2. Type 'L'
  3. Type 'o'
  4. Type 'r'
  5. Type 'd'

The sequence of events is then the following.

  1. After step 1, nothing happens
  2. After step 2, a beforematch event is fired on #foo at the next rendering opportunity.
  3. Script runs and removes the content-visibility: hidden-matchable style.
  4. At the next rendering opportunity the rendering is updated and the 'Lorem' text is scrolled into view ('L' is highlighted)
  5. After step 3 and 4, no further beforematch events happen. 'Lor' is highlighted in 'Lorem' in response to user typing
  6. After step 5, no beforematch events happen, #foo remains visible since it no longer has hidden-matchable style. The 'Lord' text is scrolled into view and 'Lord' of 'Lord of the Rings' is highlighted in response to user input

@josepharhar
Copy link
Contributor Author

You could also explore having a grace period before locking the page out of the beforematch functionality - i.e. the first beforematch event starts a timer, and you have to show some content which had a match before the timer elapses (maybe with a "last chance" callback?). That would be a much more complicated API, though.

I agree that trying to avoid locking the page out of beforematch is important. The current requestAnimationFrame delay in the algorithm should help prevent it from happening. I'm hesitant to have an actual timer, since pages shouldn't have an actual timed delay before revealing the content, right...? The current algorithm works for setTimeout(/* reveal content */, 0) and requestAnimationFrame(/* reveal content */) in the beforematch handler.

Earlier today I was toying with the idea of checking to see if the match has been revealed and trying to scroll both before as well as after the requestAnimationFrame in order to avoid the situation where the beforematch handler reveals the content and then something else in the page decides to hide it again or remove it from the dom before the browser checks after the requestAnimationFrame.

Another option might be to do something similar to what the Scroll To Text Fragment API did, and only match on word boundaries. Was that explored in the security/privacy review?

If I understand this correctly, then it seems like it would break existing find-in-page feature. Currently the match happens if any substring of the word (or multiple words) matches the query. If we only update the match on word boundaries, it seems like it would break use-cases. If we only fire beforematch on word boundaries, it seems like it would be hard to explain and also limit use-cases

Yeah, I think this could be limiting and counter-intuitive, and I don't understand how it would be a useful mitigation for beforematch. We are concerned with the page being able to build out what the user is searching for iteratively, in which case all matches would be for entire words.

showing and potentially hiding, and then "Lorem ipsum" showing... a lot of rapid changes back to back.

This seems like the current behavior of find-in-page (more or less, in Chromium at least): each character typed will instantly find the next match that matches the whole query and scroll to it. The scroll can also be observed by script and it can react in any way it wants to after the scroll happens. The added feature with this proposal is that there's an interleaved event which is able to modify style/dom before the scroll occurs.

After thinking about this some more, I think that "rapid changes" already describes the status quo: by having instant synchronous scrolling to whatever match you type into find-in-page today, chances are that the page is going to be jumping around as it scrolls to the match for whatever character you just typed in. If the page reveals content in response to each time that happens, is it really different from the status quo? Is it really that bad?

I'd just like to clarify my understanding of what happens with a specific example. Let me know if I'm correct in understanding this

Yes, this is the behavior that I'm proposing.

@alice
Copy link
Contributor

alice commented Oct 23, 2020

I think the distinction here is that beforematch fires on hidden-matchable content to allow script to make the match visible; once visible, the find-in-page finds the text as before without the need for beforematch.

Ahhh that's what I was missing. Thanks for the worked example showing this.

If the page reveals content in response to each time that happens, is it really different from the status quo? Is it really that bad?

I think it has the potential to be more annoying than the status quo, because it's expanding parts of the page that you're not interested in (and thus triggering layout and content moving around) rather than just scrolling to them.

I'm imagining a mobile wikipedia page potentially expanding 2-3 sections before the one you're actually interested in. Then you try to scroll back up to where you were before you did a search, but there's all this extra content there.

Could we do an experiment to find out?

I'm hesitant to have an actual timer, since pages shouldn't have an actual timed delay before revealing the content, right...?

I'm speculating that pages may want to avoid the annoying behaviour described above by, say, waiting for either a pause in typing or for a minimum number of characters matched, before showing the match. Forcing pages to show content for every single beforematch event rules this out. But maybe I'm wrong anyway and nobody will want to do this.

@smaug----
Copy link

How does "The beforematch event will only be fired on text hidden with the content-visibility: hidden-matchable CSS property." help with anything? The page could paint all the text in a canvas, but have the DOM in hidden-matchable.
And to overcome with (2), the page could keep the now visible DOM behind the canvas. Or just scroll the page wherever on a scroll event listener, since scroll event fires before painting.
Or am I missing something here?

@josepharhar
Copy link
Contributor Author

Thanks for the questions at TPAC @smfr and @emilio

It sounds like there were two issues brought up:

  1. Only firing the beforematch event if a certain css property is present is unusual
  2. If the page doesn’t add a beforematch event handler in time before the user starts find-in-page, it would block future beforematch events and downgrade the user experience

The intent of beforematch is to give the page a signal when a match has been found in otherwise hidden content. We would like to prevent firing the signal on visible content, since it allows the page to annotate all content with beforematch handlers and track the user’s find-in-page activity. Such information is already available at low resolution (ie scroll offset of the match), but beforematch would be able to provide more detailed information. For this reason, we prefer to focus the use-cases on hidden content that must be revealed once the match has been found.

I agree that we want to avoid downgrading the page as much as we can. I think we could look for a beforematch event listener before firing the event, and if there isn’t one, then we could avoid firing the event or prevent downgrading from occurring, since in this case the page is not capable of tracking or recording any new information provided by the beforematch event.
Right now the event is supposed to bubble, which would make it harder to figure out if there is an event listener, but I still think it would be possible even with bubbling.

How does "The beforematch event will only be fired on text hidden with the content-visibility: hidden-matchable CSS property." help with anything? The page could paint all the text in a canvas, but have the DOM in hidden-matchable.
And to overcome with (2), the page could keep the now visible DOM behind the canvas. Or just scroll the page wherever on a scroll event listener, since scroll event fires before painting.
Or am I missing something here?

Since content that is occluded will still be find-in-pageable even today, yet will yield a poor/broken user experience if found, the attack surface is not increased for such content due to the feature, and developers are already dis-incented to do such things because it results in a poor UX.

Although you can listen to scroll events, providing a higher precision and easier way for the page to snoop find-in-page via beforematch without the content ever being drawn on the screen (content-visibility:hidden-matchable) seemed problematic.
This combined with the fact that we were adding a non-zero information delta from find-in-page to the page by firing beforematch on visible content is a privacy problem.
Scroll events can be used to determine what text was searched for if you make the small amount of text take up most of the viewport, but most pages do not look like this and beforematch would be revealing more information to them.

In other words, the mitigations we are proposing for the beforematch event are meant to make it difficult to use it to track the user’s find-in-page activity, but easy to use to make hidden content visible. The tracking portion of this is largely possible without beforematch (using scroll offsets). This means that with our mitigations, beforematch is not an easier tool for getting this information.

@chrishtr
Copy link
Contributor

Also, see this comment on the CSSWG issue, clarifying that we propose to adjust the spec for text fragments, and not just find-in-page.

josepharhar added a commit to josepharhar/display-locking that referenced this issue Nov 4, 2020
@josepharhar
Copy link
Contributor Author

In the parallel csswg issue, it was brought up that we should also fire the event on element fragments. this means that we’ll need to modify the Navigating to a Fragment Algorithm to fire the beforematch event at the appropriate time.

@fantasai
Copy link
Contributor

fantasai commented Dec 1, 2020

Point was, there shouldn't be a difference in behavior for a fragment targetted via text vs. ID; if someone is linking into some content using a fragID, they should get the same effect whatever type of fragID it is.

@josepharhar
Copy link
Contributor Author

The tag review has been approved.

Regarding support for element fragments, I think they should be supported but with slightly different timing with regards to scrolling compared to find-in-page and ScrollToTextFragment. I documented the constraints and behavior in the explainer here.

I anticipate that this feature will be approved by the CSSWG soon. Is there anything I can to do push this along? Can I open an HTML spec PR for this feature?

@domenic
Copy link
Member

domenic commented Feb 9, 2021

My understanding is the CSSWG operates by consensus, so if the feature is approved, then you'll have multi-implementer interest in the sense HTML requires. So yeah, a HTML spec pull request would be a great next step.

@josepharhar
Copy link
Contributor Author

Based on feedback from CSSWG, I am now proposing that this feature will be an HTML attribute instead of a CSS property, as suggested here.

Instead of the content-visibility: hidden-matchable CSS property, we will have the hidden=until-found attribute.
hidden=until-found will make the content hidden similarly to the hidden attribute via a user-agent stylesheet.
Using an HTML attribute has the advantage that it can be removed automatically by the browser instead of relying on the beforematch event handler to reveal the content.
The beforematch event will still be needed in order to support use cases where other state in the page must be changed when content is expanded, but it won't be required in order to make the feature work.

The user-agent stylesheet for hidden=until-found will use content-visibility: hidden in order to have improved performance.
If we instead used display: none, we would need to fully compute style and layout for the hidden=until-found subtree every time a search is made.
If we instead used visibility: hidden, then we would need to fully compute style and layout for the hidden=until-found subtree on page load.
The user-agent stylesheet would look something like this:

[hidden=until-found] { content-visibility: hidden }

[hidden]:not([hidden=until-found]) { display: none }

Overloading the existing hidden attribute is good because hidden=until-found already hides the content today in all browsers, so browsers which haven't implemented this feature yet will still render the content as expected, but just won't support expanding the content - webpages can effectively be written exactly the same without having to worry about which browsers support the feature or not.
On the other hand, the hidden attribute currently uses display: none, which is incompatible with content-visibility: hidden - content-visibility: hidden needs a layout box in order to work, which means that hidden=until-found will need to have something other than none for display, and may render borders and things which the hidden attribute by itself would not render.

Here is a quick example of how this would be used:

<div id=sectionheader></div>
<div hidden=until-found id=collapsible>
  hidden text
</div>
<script>
let revealed = false;
sectionheader.textContent = 'section (hidden)';
collapsible.onbeforematch = () => {
  revealed = true;
  sectionheader.textContent = 'section (revealed)';
};
sectionheader.onclick = () => {
  if (revealed) {
    revealed = false;
    collapsible.setAttribute('hidden', 'until-found');
    sectionheader.textContent = 'section (hidden)';
  } else {
    revealed = true;
    sectionheader.textContent = 'section (revealed)';
  }
};
</script>

@josepharhar josepharhar changed the title Proposal: beforematch event and content-visibility: hidden-matchable Proposal: beforematch event and hidden=until-matchable attribute Feb 25, 2021
@annevk
Copy link
Member

annevk commented Feb 25, 2021

You'll also need to adjust embed[hidden] { display: inline; height: 0; width: 0; } somehow, I suspect. Though perhaps that rule can be removed due to plugin removal?

One thing to look out for is that we don't create another event with mutation-event timing as we do more of these user-action-mutates-the-tree features.

@annevk
Copy link
Member

annevk commented Feb 25, 2021

In the CSS issue @smaug---- raised a question about the hidden IDL attribute. What's the idea there, add a new IDL attribute that can reflect all three states?

@hftf
Copy link

hftf commented Feb 25, 2021

I wrote a comment in the CSSWG thread about how searchability is the desired new primitive. I think hidden=until-found will conflate two distinct concepts, visibility and searchability, and will not scale to accommodating similar use cases for controlling searchability (such as w3c/csswg-drafts#3460).

I'm also not convinced that searchability belongs in HTML rather than CSS. Wouldn't a CSS property have the same advantage of not requiring JS event handling to make the feature just work by default, and require the exact same amount of computation as an HTML attribute? (Or even less, since for HTML you have to re-add hidden=until-found manually after each search, and thus its searchability-related semantics toggles on and off.) Please let me know if I understood it wrong.

<p style="user-search: always-searchable; display: none;">Hidden but searchable</p>
<!-- CSS property (hypothetical, illustrative only):
1. Load page, resolve styles, build text index (including within `user-search: always-searchable`).
2. User searches for "Hidden".
3. Find match in <p> with `user-search: always-searchable; display: none;`.
4. Unhide it automatically and re-render.  -->

<p hidden="until-match">Hidden but searchable</p>
<!-- HTML attribute:
1. Load page, resolve styles, build text index (including within `hidden="until-match"`).
2. User searches for "Hidden".
3. Find match in <p> with `hidden="until-match"`.
4. Unhide it automatically and re-render. -->

A CSS property would also allow concisely controlling the searchability of many elements at once using the power of selectors, while an HTML attribute would require repetitively adding an attribute on each element.

@vmpstr
Copy link
Member

vmpstr commented Feb 25, 2021

4. Unhide it automatically and re-render.

Can you elaborate on how the "unhiding" would happen automatically in the CSS case? The problem is that the visibility (or lack thereof) can come from some class somewhere, and it's unclear to me how one would "make it visible" automatically. And even if this is possible, then how would one hide the content again?

As for the attribute version, the proposal is that it would come with some automatic hiding, but that would be specified as the UA stylesheet, so the author would be able to override the default behavior

@hftf
Copy link

hftf commented Feb 26, 2021

visibility (or lack thereof) can come from some class somewhere, and it's unclear to me how one would "make it visible" automatically

Sorry, I was oversimplifying and meant "attempt to unhide it" instead. In both the HTML attribute and CSS property cases, isn't it equally true that visibility can derive from elsewhere? The browser still needs to compute styles fully to decide whether the matched hidden content can actually be shown, right? And isn't it CSS that ultimately sets the invisibility of [hidden] already (via the UA stylesheet, which you mention the user would be able to override)?

<div hidden> or <div style="display: none;"> or <div class="hide"> or `div > p { display: none; }` etc.
  <p hidden="until-match">Hidden but searchable</p>
</div>

I'm now also wondering, is it really that desirable for this feature to work by default (i.e. not require any JS)?

@vmpstr
Copy link
Member

vmpstr commented Feb 26, 2021

Yes, you're right that the visibility can still come from a number of properties. The nice thing about an attribute is that it affects this one element (and only this one element). So, in response to a find-in-page match, the UA can remove the attribute. This accomplishes one of two things: either this clears sufficient style making content visible and thus the match can be shown to the user, or if the content is still hidden then it makes that content no longer searchable (since it no longer has hidden=until-found).

With CSS, the UA can remove the inline style since that only affects the one element, but there is no elegant way to remove other rules that would affect the element, such as div > p { display: none; } in your example.

As for the question of whether this should work without JS: it's definitely a nice-to-have. The previous proposal of content-visibility: hidden-matchable did not have any automatic ways of revealing content and relied exclusively on beforematch and JS to reveal content. With an attribute, we can unlock more cases that can work without script (e.g. the <details> element would benefit from this naturally)

@domenic
Copy link
Member

domenic commented Feb 26, 2021

Based on feedback from CSSWG, I am now proposing that this feature will be an HTML attribute instead of a CSS property, as suggested here.

This plan looks good to me. To summarize and reiterate the tricky parts:

  • Changing a boolean attribute into a tri-state attribute will be tricky.

    • You'd change it to be an enumerated attribute with three states: e.g. true, until found, and false. The invalid value default would be true and the missing value default would be false. The attribute's keywords would be the empty string and "until-found", with the empty string mapping to the true state and "until-found" mapping to the until found state. We could also add a "false" keyword mapping to the false state, which would be more consistent with some of the other similar attributes, but might cause compat problems.

    • Speaking of other similar attributes, consistency is totally lost. We have: draggable (true/false + auto); translate (yes/no); autocomplete (on/off); contenteditable (true/false + inherit); spellcheck (true/false + default); autocapitalize (on = sentences/off = none/words/characters). Also a lot of ARIA attributes which use true/false. I picked true/false since it seems slightly more prevalent but I don't have a strong opinion.

  • IDL reflection gets impacted. I see two paths:

    • Double reflection: leave element.hidden as a boolean with the same logic as now (i.e., any value, including until-hidden, always returns true). Add some new property, e.g. el.hiddenState, which does the enumerated reflection. In this case you'd want to modify my above enumerated attribute by adding a "true" keyword mapping to the true state and saying that one is canonical; this has no impact on compat but it ensures that element.setAttribute("hidden", "asdf"); element.hiddenState returns "true" not "".

    • Custom reflection: leave element.hidden as a boolean with the same logic as now (i.e., any value, including until-hidden, always returns true). Do not add a new property.

      This is similar to how element.draggable, element.contentEditable, and element.spellcheck work; when you're in their third state, they return whatever is "really" happening. For hidden="" that is pretty straightforward though: since the browser removes the hidden="" attribute when finding the element, if hidden="until-found" is on the element, then the element is still hidden, so element.hidden should return true.

  • Event timing. Be sure that the event is on a queued task. In particular I believe the case @annevk is concerned about is something like:

    1. The user types "foo" in their search bar. No matches.

    2. The page author does someElement.textContent = "foo".

    This should remove the hidden="" attribute, and fire the event, asynchronously---not synchronously---from the textContent setter.

@josepharhar josepharhar changed the title Proposal: beforematch event and hidden=until-matchable attribute Proposal: beforematch event and hidden=until-found attribute Apr 30, 2021
@josepharhar
Copy link
Contributor Author

I have an updated explainer here: https://github.com/WICG/display-locking/blob/main/explainers/hidden-content-explainer.md
I opened a PR here: #7475

@josepharhar
Copy link
Contributor Author

Changing a boolean attribute into a tri-state attribute will be tricky.

You'd change it to be an enumerated attribute with three states: e.g. true, until found, and false. The invalid value default would be true and the missing value default would be false. The attribute's keywords would be the empty string and "until-found", with the empty string mapping to the true state and "until-found" mapping to the until found state. We could also add a "false" keyword mapping to the false state, which would be more consistent with some of the other similar attributes, but might cause compat problems.

I don't fully understand this concern if we are just talking about the attribute itself and not the reflected element.hidden IDL.

If the attribute isn't present, the element isn't hidden, like it is today.
If the attribute is 'until-found', then do our new logic.
If the attribute is anything else, then make it hidden like it would be today.

IDL reflection gets impacted. I see two paths

So there's no way we can support element.hidden = 'until-found'?

Custom reflection: leave element.hidden as a boolean with the same logic as now (i.e., any value, including until-hidden, always returns true). Do not add a new property.

I'm inclined to choose this option, but maybe if people really want to avoid element.setAttribute() then adding another property would be helpful.

@domenic
Copy link
Member

domenic commented Jan 10, 2022

I don't fully understand this concern if we are just talking about the attribute itself and not the reflected element.hidden IDL.

I think for the content attribute there's no concern. I was just mentioning that the spec prose will be tricky. I haven't looked at the PR yet to see how that went; will comment over there :).

So there's no way we can support element.hidden = 'until-found'?

I guess we could; we would just abandon the idea of hidden using the spec's existing reflection infrastructure and write custom getter steps and setter steps. That's probably fine though.

I'm inclined to choose this option, but maybe if people really want to avoid element.setAttribute()

One of the big benefits of adding properties instead of forcing the use of setAttribute() is that some frameworks, like React, are property-centric.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
addition/proposal New features or enhancements needs implementer interest Moving the issue forward requires implementers to express interest
Development

No branches or pull requests

10 participants