Skip to content

Steppers and the click queue

A booker changing their mind is a run of clicks, not one click: four presses on a plus button, or six places picked off a plan in a few seconds. Two things in yoyaku_presentation make that run behave like one decision, and every booking surface uses both rather than writing its own. A third thing there is what a quantity is offered in: the card an offer is drawn as.

The stepper

Drupal.yoyaku.stepper(input, options) turns a number field into a minus / value / plus control. The field stays the control: the buttons only drive it, so a visitor who would rather type a number meets one control rather than two, and a visitor without the script keeps a working field.

What it takes care of, in every place a quantity is offered:

  • The buttons are named after what they change. A page offers several quantities, and identical "one more" names on all of them tell a screen reader user nothing about which one they are about to change.
  • It announces the new quantity. Focus stays on the button, so a value written into a field nobody is on is announced by nothing.
  • The ceiling is marked aria-disabled, never disabled. A disabled button loses focus, so a keyboard visitor pressing plus up to the maximum would be dropped to the top of the page at the moment they got there.
  • The bounds are read on every use, never captured: the answer moves the ceiling as other people book, and a stepper holding the ceiling it was built with would go on offering what has gone. The booker's own choices move it too, on a page that lets them ask for one part of the house: the ceiling is then what that area has left rather than what the tariff has left everywhere (see Asking for an area). A per-order rule lowers it again, on both surfaces that offer a quantity (see What a per-order rule does on the plan).
  • It says why it stops, where the surface can say it. A ceiling region is what the plus button is pointed at, as well as the field, because the visitor who runs into a ceiling is standing on that button and is not reading the field's description; and pressing on at a bound reads the region back aloud, since aria-disabled is announced on arriving at a button rather than on pressing one already under the finger. A button that goes quiet and says nothing is what a broken control sounds like. Nothing is invented here: the region is the surface's to fill, and an empty one stays silent.
  • It reports two things to the surface. change is the value the field now holds, however it got there, which is what the visitor is asking for; commit is that value becoming worth posting, which a button press is at once and a typed number is only once the field is left. "13" must not hold 1 and then 13.

The queue

Drupal.yoyaku.queue(config) is what a surface posts through.

  • One delay for the whole site, Drupal.yoyaku.delay. A place is contended in a way a quantity is not, which is an argument for two numbers; the booker is one person, which is the argument that won. The site sets it, on the booking settings screen, in milliseconds: how fast a venue's bookers click and how far they sit from the server is a property of the venue rather than of the surface they are on. Four hundred milliseconds is the default, kept in the JavaScript, and a site that never touches the setting runs on it. The number reaches the browser only on a page that actually loads the queue.
  • One request at a time. A click made while a request is out cannot ride along with it, so it waits, and the answer sends it. It is never dropped. A surface that guards on a busy flag instead loses clicks made while it is waiting, and says nothing about losing them.
  • What was asked for and what was posted are both kept. Comparing the answer against what was posted is what tells a server correction (apply it, and say so) from a click that landed after the request left (send it again). Comparing only against the answer sends a refused ten again for ever.
  • A watchdog, because an answer that never arrives would otherwise leave the surface permanently unable to send.
  • A transport may refuse. send() returning false means nothing left, so the queue does not start waiting for an answer; it offers the same thing again shortly. Note that "is an answer still coming?" and "may I dispatch?" are two different questions, and conflating them loses the gesture: the first holds a click back and stops being true the moment the answer is applied, the second can stay true well past it. Both surfaces post through fetch to an endpoint of their own, which never refuses for the second reason, and the two questions stay apart all the same because the queue is what keeps them apart.
  • drain() sends what is waiting and resolves when the queue has run dry, for the operations that are not clicks at all: handing the basket to checkout, moving a place, changing an offer. Drupal.yoyaku.drainAll() does it for every queue on the page, which is how the checkout button waits without knowing which surfaces are on it.

What travels is the surface's business. The queue asks for a snapshot when a request is about to leave, hands it to the surface's transport, and reconciles whatever the surface reports back through settle(), one verdict per thing the answer names: corrected (the server holds something else, so it is right and the screen is wrong), behind (the visitor moved after the request left, so the screen is ahead rather than wrong) or agreed.

The card an offer is drawn as

A quantity is always a quantity of something, and where a surface renders that something on the server it renders yoyaku_offer: the theme hook, yoyaku-offer.html.twig and the yoyaku_presentation/offer library, which pulls the stepper in with it. The card names the offer, the places it covers, what is left of it, the price, whatever a module adds to the price, and the constraints holding it back, all around the quantity control. A site restyles an offer, everywhere one appears, by overriding that one template.

Where a surface is shared by every visitor the card is rendered with its availability line, its limits and its messages present and EMPTY, and the browser fills them in. A live region created at the moment it has something to say is announced by nothing, and a page that printed what was left could not be kept past the moment it stopped being true.

It lives here rather than with the page that first drew it for two reasons. A module that adds something to an offer preprocesses the hook, and a module has to be able to declare the module owning a hook it preprocesses: that is how yoyaku_booking_fee mentions a fee beside the price with no calendar installed. And the surface that draws offers is not necessarily the one that started them.

The map's selection panel does not use it, deliberately. It offers areas by quantity too, but it builds its list in the browser, where there is no template to render, and it has a few hundred pixels beside a plan to do it in, where an area's tariffs read as entries of the same list as the places picked above them. Dressing them as cards would make the panel something else, and giving them the card's class names while overriding every rule in the card's stylesheet would put a lie in it. Two surfaces, two shapes; what they share is the stepper, the queue and the messages, which is what a booker actually meets twice.

Which kind of message it is

Every sentence carries a type, and the three are core's own: status for a plain confirmation, warning for something done that the booker did not ask for, and error for something that did not happen. The server sends the type beside the text, and the surface maps it to a color rather than choosing one: green, amber and red, each measured against white so it is readable at the size the sentence is set in.

The color never carries the difference on its own. The sentence says which it is, and that is the half of it a booker who cannot tell the three apart reads. It is the same vocabulary the seat map answers in, so a booker who meets both surfaces meets one.

A surface says it through Drupal.yoyaku.Message, which is core's Drupal.Message in miniature: new Drupal.yoyaku.Message(region), then add(text, {type, id}) and clear(). The region is named to the constructor because a booking page's messages sit beside the control that produced them rather than in one place at the top, so there is one per card and no default to fall back to.

The type decides how urgently the region is read out, not just how it looks. A region goes to role="alert" as soon as anything standing in it is news the booker is waiting on, and back to role="status" while it is all notes about what was done, which is core's rule and the seat map's. It is set on the region rather than on a line, because the region is the live one: a sentence inside it is not announced on its own, so one urgent sentence makes the whole region urgent while it is there.

It goes through Drupal.announce(), which core built for this and which takes the priority as an argument. The card's own region is deliberately not a live region: a live region caches its politeness when assistive technology first registers it, so changing its role or aria-live afterwards is unreliable - some screen readers ignore the change, some treat it as a new region and read whatever is already sitting there, and changing the politeness and the text in one tick races the two. Core sidesteps all of it by owning one region and bracketing the change with aria-busy, so the politeness is already the new one by the time the text lands.

Which also settles how many voices there are. The region on the card is an ordinary paragraph, rendered empty and already in the page because the control is described by it; the sentence is written there for whoever can see it and announced once for whoever cannot. Making it live as well would say everything twice, in two mechanisms that disagree about how loudly.

Only an error interrupts. A warning reports something already done with the booking still made, and cutting across whatever the booker is having read to them is not what that deserves.

How long a message stays

A surface answers in a sentence whenever something happened that the controls cannot show on their own: fewer places held than asked for, an area that filled up and took a choice with it, places moved so that a party can sit together, or a claim that could not be sent at all. The sentence goes in the live region the card was rendered with, and two rules govern it.

Nothing takes a message away before it has been read. Every path that used to empty the region ran on a gesture of the booker's - a press on plus, a keystroke, the answer to the click they just made - which is the one moment they are certainly not reading. So the surfaces ASK the region to empty, and it keeps whatever is too young to have been read, retiring it on its own timer afterwards. Keep a message on screen for at least on the booking settings screen is that age, five seconds by default and a second at the very least, and it is the same number the seat map keeps its own messages for: how long a booker needs to read a sentence is a property of the house rather than of the surface saying it.

A second thing worth saying goes under the first. Both are true, and showing only the newest loses whichever news the booker had not got to. Saying something already on screen refreshes its line instead of printing it twice, which is the common case: a run of clicks against a full house says the same sentence every time.

Unless it is the same thing said again. A message may name a topic, and a later message on that topic rewrites the line on the spot rather than joining it, with none of the waiting above: the refresh control's result supersedes its "checking for changes", a fresh correction supersedes a stale one about the same offer, and an area's count supersedes the count before it. What guarantees a sentence its time on screen is that nothing takes it away, and news about the very thing it describes is not taking it away, it is correcting it. Two things that happened are two lines; one thing that changed is one line.

What each surface does with it

Surface What a request carries How the answer arrives
The booking page's offer cards The whole selection, as absolute quantities, to the claim endpoint The claim's answer, one entry per offer the request named
The seat map: places and pooled areas What to give back and what to take, as a difference between what is painted and what the server last confirmed The place operation's answer, naming every place and area the request mentioned

Both post to an endpoint of their own rather than submitting anything. A form has to be BUILT in order to be submitted, so a page that answered a click by rebuilding itself paid for two page builds per booking, on a page a cache had already paid for once.

The map paints first and posts after, so a place turns color under the finger and the count moves while the request is still being written. What the map is showing and what the server last confirmed are kept apart, and the difference between the two is the request: it is what has not been posted yet. The places in that difference are kept as a set rather than looked for, because looking for them means walking the hall, and a hall is as big as it likes.

What a booker sees while a click is on its way

Three rules, and the first one decides the other two.

The booker's color means the server holds this place for you, and nothing else may wear it. A click that has not been answered for is drawn in a color of its own, so the plan never promises something the next answer could take back.

The pointer carries the wait. A place is too small to carry a marker legibly at map scale, so the map's cursor says a request is out. It is progress and not wait: the map goes on taking clicks, which is the entire point of the queue, and wait would say the opposite.

The selection panel states only what is held. It carries a total and controls that act on the server, so a line appearing there and being taken away again is a far louder retraction than a place changing color - and the total beside it would have named a figure that was never true. What is on its way is on the plan; what is in the panel is held. The same goes for a pooled area's units: the stepper moves at once, because it is the control being operated, but its units join the total when the server agrees.

None of this is carried by color alone: a place waiting for its answer is aria-busy, and says "not confirmed yet" in its own name.

One request, one lock

The place endpoint takes a list. A run of clicks is one request, and its places are held together: one pass of the engine, so the slot is locked once however many places the party picked. A booking of several hundred places is one request taking that lock once, rather than several hundred taking it in turn.

Together does not mean all or nothing. A place somebody else took in the meantime, or one a rule refuses this booker, is named in a refusal list while everything else is held. Only when the group is refused does the endpoint go back over it one ask at a time, which is the one case where paying for several locks gains something.

Giving back comes first within a request, deliberately: a booker who releases one place and takes another in the same breath is owed the capacity they just handed over.

Saying no

A place is announced as asked for when it is clicked, and as booked when the answer says it is. The two are different sentences on purpose: naming a click as done, before it has left the browser, is the same promise the color used to make.

A refusal is said out loud and put on screen, wherever it happens. A stepper that will not move is quieter than a place that will not change color: nothing about the area changes at all, so without a message the click reads as having done nothing and the reason a rule gave for refusing goes unread. The server's own words are used wherever it sent any.