Skip to main content

tablo_core/schema/
fields.rs

1//! One [`Field`] type whose control is text, choice, file, or an app's own [`Control`], built from
2//! a path binding a column or an embedded leaf.
3
4mod builders;
5mod choice;
6pub(crate) mod custom;
7mod file;
8mod text;
9
10use std::sync::Arc;
11
12pub use builders::{ChoiceField, CustomField, FileField, IntoOptions, RepeaterField, TextField};
13pub(crate) use choice::{ChoiceControl, option_view};
14pub use custom::Toggle;
15pub(crate) use custom::{Control, ControlInput};
16pub(crate) use file::stored_upload;
17use tablo_ui::{
18    field as ui_field, field_content as ui_field_content, field_error as ui_field_error,
19    field_label as ui_field_label, field_title as ui_field_title,
20};
21pub(crate) use text::TextControl;
22use toasty::stmt::{List, Path};
23use topcoat::{Result, context::Cx, view::*};
24
25use super::{
26    condition::Condition,
27    lenses::{Binding, FieldResolver},
28    repeater::{RepeaterControl, RepeaterItem},
29    validation::is_email,
30};
31use crate::{
32    form::{FieldError, FormScalar},
33    naming::capitalize,
34};
35
36/// One form field binding a lens to the control editing it, offering only that control's modifiers
37/// so a modifier on the wrong control does not compile.
38///
39/// ```rust
40/// # #[derive(Debug, Clone, toasty::Model)]
41/// # struct User {
42/// #     #[key] #[auto] id: uuid::Uuid,
43/// #     name: String,
44/// #     email: String,
45/// #     age: i64,
46/// # }
47/// # #[derive(Debug, Clone, toasty::Model)]
48/// # struct Post {
49/// #     #[key] #[auto] id: uuid::Uuid,
50/// #     body: String,
51/// #     status: String,
52/// #     author_id: uuid::Uuid,
53/// #     featured: bool,
54/// # }
55/// # #[derive(Debug, Clone, toasty::Model)]
56/// # struct Doc { #[key] #[auto] id: uuid::Uuid, path: String }
57/// # #[derive(Debug, Clone, toasty::Model)]
58/// # struct Author { #[key] #[auto] id: uuid::Uuid, name: String }
59/// # #[derive(Debug, Clone, Copy, PartialEq, Eq, tablo_core::Options)]
60/// # enum Status { Draft, Published }
61/// # struct AuthorResource;
62/// # impl tablo_core::extend::OptionSource for AuthorResource {
63/// #     type Model = Author;
64/// #     fn scoped_query(_cx: &topcoat::context::Cx)
65/// #         -> topcoat::Result<toasty::stmt::Query<toasty::stmt::List<Author>>>
66/// #     {
67/// #         Ok(toasty::stmt::Query::all())
68/// #     }
69/// #     fn label(_cx: &topcoat::context::Cx, author: &Author) -> String {
70/// #         author.name.clone()
71/// #     }
72/// # }
73/// # use tablo_core::{Field, Options, lens};
74/// Field::text(lens!(User.name)).placeholder("Ada Lovelace"); // TextField
75/// Field::text(lens!(User.email)).email().unique();
76/// Field::text(lens!(User.age)); // typed: an `i64` column
77/// Field::text(lens!(Post.body)).multiline(6);
78/// Field::choice(lens!(Post.status)).options(Status::options()); // ChoiceField
79/// Field::choice(lens!(Post.author_id)).relationship::<AuthorResource>();
80/// Field::file(lens!(Doc.path)); // FileField
81/// Field::toggle(lens!(Post.featured)); // CustomField
82/// ```
83///
84/// ```compile_fail
85/// # #[derive(Debug, Clone, toasty::Model)]
86/// # struct User { #[key] #[auto] id: uuid::Uuid, name: String }
87/// # fn main() {
88/// tablo_core::Field::text(User::fields().name()).options(["admin", "member"]);
89/// # }
90/// ```
91pub struct Field {
92    binding: Binding,
93    /// The declared label, over the binding's.
94    label: Option<String>,
95    /// Whether the control renders as required: the panel sets it from the record form, and an
96    /// embedded value's derive from its own fields.
97    required: bool,
98    control: ControlKind,
99    /// The condition showing the field, set by `visible_when`.
100    condition: Option<Condition>,
101    /// Whether the control is a checkbox, which a condition follows through `checked`.
102    checkbox: bool,
103}
104
105/// The control a [`Field`] renders, with what only that control declares.
106pub(crate) enum ControlKind {
107    /// A one-line `<input>`, or a `<textarea>` when `rows` is set.
108    Text(TextControl),
109    /// A `<select>` over static options or a relationship's rows.
110    Choice(ChoiceControl),
111    /// A file input storing the uploaded file's path.
112    File,
113    /// An app's control, or the built-in [`Toggle`].
114    Custom(Arc<dyn Control>),
115    /// A row of an item's controls per value of a `#[document]` list.
116    Repeater(RepeaterControl),
117}
118
119impl std::fmt::Debug for Field {
120    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
121        let control = match &self.control {
122            ControlKind::Text(_) => "text",
123            ControlKind::Choice(_) => "choice",
124            ControlKind::File => "file",
125            ControlKind::Custom(_) => "custom",
126            ControlKind::Repeater(_) => "repeater",
127        };
128        f.debug_struct("Field")
129            .field("name", &self.name())
130            .field("label", &self.label_str())
131            .field("required", &self.required)
132            .field("control", &control)
133            .field("condition", &self.condition)
134            .finish()
135    }
136}
137
138impl Field {
139    fn bound(binding: Binding, control: ControlKind) -> Self {
140        Self {
141            binding,
142            label: None,
143            required: false,
144            control,
145            condition: None,
146            checkbox: false,
147        }
148    }
149
150    /// Binds any [`FormScalar`] column, storing the type's own spelling and probing uniqueness
151    /// through the lens.
152    pub fn text<M, T>(lens: impl Into<Path<M, T>>) -> TextField
153    where
154        M: toasty::schema::Model,
155        T: FormScalar + toasty::stmt::IntoExpr<T> + 'static,
156    {
157        let path: Path<M, T> = lens.into();
158        let binding = Binding::of(&path);
159        let control = TextControl::new::<M, T>(path, binding.unique());
160        TextField::new(Self::bound(binding, ControlKind::Text(control)))
161    }
162
163    /// The text field `#[derive(EmbeddedForm)]` renders for a leaf.
164    #[doc(hidden)]
165    pub fn embedded_leaf<M, T>(path: Path<M, T>) -> TextField
166    where
167        M: toasty::schema::Model,
168        T: FormScalar,
169    {
170        TextField::new(Self::bound(
171            Binding::of(&path),
172            ControlKind::Text(TextControl::leaf::<T>()),
173        ))
174    }
175
176    /// A choice field over any column, with options from [`options`](ChoiceField::options) or
177    /// [`relationship`](ChoiceField::relationship).
178    pub fn choice<M, T>(lens: impl Into<Path<M, T>>) -> ChoiceField
179    where
180        M: toasty::schema::Model,
181    {
182        ChoiceField::new(Self::bound(
183            Binding::of::<M, T>(&lens.into()),
184            ControlKind::Choice(ChoiceControl::default()),
185        ))
186    }
187
188    /// A file field over a `String` column holding the uploaded path, rendering no `value`
189    /// attribute.
190    pub fn file<M>(lens: impl Into<Path<M, String>>) -> FileField
191    where
192        M: toasty::schema::Model,
193    {
194        FileField::new(Self::bound(
195            Binding::of::<M, String>(&lens.into()),
196            ControlKind::File,
197        ))
198    }
199
200    /// A checkbox over a `bool` column that submits `false` when unchecked.
201    pub fn toggle<M>(lens: impl Into<Path<M, bool>>) -> CustomField
202    where
203        M: toasty::schema::Model,
204    {
205        let mut field = Self::custom(lens, Toggle);
206        field.0.checkbox = true;
207        field
208    }
209
210    /// A field over any [`FormScalar`] column, rendered by an app's [`Control`].
211    pub fn custom<M, T>(lens: impl Into<Path<M, T>>, control: impl Control + 'static) -> CustomField
212    where
213        M: toasty::schema::Model,
214        T: FormScalar,
215    {
216        CustomField::new(Self::bound(
217            Binding::of::<M, T>(&lens.into()),
218            ControlKind::Custom(Arc::new(control)),
219        ))
220    }
221
222    /// A row of `T`'s controls per item of a `#[document]` list of `T`, which the browser adds,
223    /// removes and moves: a record form's `#[form(repeat)]` control.
224    ///
225    /// Each row posts its item's keys under `{key}.{row}.`. A resource's form and an action's
226    /// input fold them into `key` before reading the submission, and a page handling its own post
227    /// folds it with [`Schema::fold_repeaters`](crate::Schema::fold_repeaters); the items then
228    /// read with [`parse_items`](crate::schema::parse_items).
229    pub fn repeater<M, T>(lens: impl Into<Path<M, List<T>>>) -> RepeaterField
230    where
231        M: toasty::schema::Model,
232        T: RepeaterItem,
233    {
234        RepeaterField::new(Self::bound(
235            Binding::of::<M, List<T>>(&lens.into()),
236            ControlKind::Repeater(RepeaterControl::new::<T>()),
237        ))
238    }
239
240    /// A text field posting `name`, a key no column binds: an [`ActionInput`](crate::ActionInput)
241    /// field. `T` picks the input type, as a column's type does for [`Field::text`].
242    pub fn text_input<T: FormScalar>(name: impl Into<String>) -> TextField {
243        TextField::new(Self::bound(
244            Self::named(name.into()),
245            ControlKind::Text(TextControl::leaf::<T>()),
246        ))
247    }
248
249    /// A choice field posting `name`, a key no column binds, with options from
250    /// [`options`](ChoiceField::options).
251    pub fn choice_input(name: impl Into<String>) -> ChoiceField {
252        ChoiceField::new(Self::bound(
253            Self::named(name.into()),
254            ControlKind::Choice(ChoiceControl::default()),
255        ))
256    }
257
258    /// A checkbox posting `name`, a key no column binds, that submits `false` when unchecked.
259    pub fn toggle_input(name: impl Into<String>) -> CustomField {
260        let mut field = Self::bound(
261            Self::named(name.into()),
262            ControlKind::Custom(Arc::new(Toggle)),
263        );
264        field.checkbox = true;
265        CustomField::new(field)
266    }
267
268    /// A binding for `name`, labelled as a column of that name would be.
269    fn named(name: String) -> Binding {
270        let label = capitalize(&name);
271        Binding::named(name, label)
272    }
273
274    /// The variant control of an embedded enum, never required since an empty submit reads the
275    /// variant from the payload.
276    pub(crate) fn discriminant(name: String, variants: Vec<(String, String)>) -> Self {
277        Self {
278            binding: Binding::named(name.clone(), capitalize(&name)),
279            label: None,
280            required: false,
281            control: ControlKind::Choice(ChoiceControl {
282                discriminant: true,
283                options: variants,
284                ..ChoiceControl::default()
285            }),
286            condition: None,
287            checkbox: false,
288        }
289    }
290
291    /// Bind an embedded path through `resolver`'s app schema.
292    pub(crate) fn bind(&self, resolver: &FieldResolver) {
293        self.binding.bind(resolver);
294    }
295
296    pub(crate) fn misdeclared(&self) -> Option<crate::DeclarationErrorKind> {
297        self.binding.misdeclared()
298    }
299
300    /// The key the field posts: the storage column its path names.
301    pub fn name(&self) -> &str {
302        self.binding.name()
303    }
304
305    pub(crate) fn label_str(&self) -> &str {
306        self.label.as_deref().unwrap_or(self.binding.label())
307    }
308
309    /// Whether the field is a choice declaring neither options nor a relationship.
310    pub(crate) fn offers_nothing(&self) -> bool {
311        self.as_choice().is_some_and(ChoiceControl::offers_nothing)
312    }
313
314    pub(crate) fn as_repeater(&self) -> Option<&RepeaterControl> {
315        match &self.control {
316            ControlKind::Repeater(repeater) => Some(repeater),
317            _ => None,
318        }
319    }
320
321    /// Posts under `prefix`: a repeater row's control, keeping its label.
322    pub(crate) fn prefix(&mut self, prefix: &str) {
323        let label = self.label_str().to_string();
324        self.binding = Binding::named(format!("{prefix}{}", self.name()), label);
325    }
326
327    pub(crate) fn as_choice(&self) -> Option<&ChoiceControl> {
328        match &self.control {
329            ControlKind::Choice(choice) => Some(choice),
330            _ => None,
331        }
332    }
333
334    /// Probes uniqueness with the submission parsed through the lens.
335    pub(crate) fn eq_filter(&self, value: &str) -> Option<toasty::stmt::Expr<bool>> {
336        match &self.control {
337            ControlKind::Text(text) => text.eq_filter(value),
338            _ => None,
339        }
340    }
341
342    pub(crate) fn is_file(&self) -> bool {
343        matches!(self.control, ControlKind::File)
344    }
345
346    pub(crate) fn is_unique(&self) -> bool {
347        matches!(&self.control, ControlKind::Text(text) if text.unique)
348    }
349
350    /// The condition showing the field, if any.
351    pub(crate) fn condition(&self) -> Option<&Condition> {
352        self.condition.as_ref()
353    }
354
355    /// Whether the control is a checkbox.
356    pub(crate) fn is_checkbox(&self) -> bool {
357        self.checkbox
358    }
359
360    /// Whether the control can post `value`: a checkbox posts `true` or `false`, and a choice over
361    /// static options one of their values. `None` when the control does not say: a text field, a
362    /// relationship, or an app's own control.
363    pub(crate) fn can_post(&self, value: &str) -> Option<bool> {
364        if self.checkbox {
365            return Some(value == "true" || value == "false");
366        }
367        match &self.control {
368            // Its key holds a list, which no condition value spells.
369            ControlKind::Choice(choice) if choice.is_multiple() => Some(false),
370            ControlKind::Choice(choice) if !choice.is_relationship() => {
371                Some(value.is_empty() || choice.label_of(value).is_some())
372            }
373            _ => None,
374        }
375    }
376
377    /// Whether the control renders as required.
378    pub(crate) fn is_required(&self) -> bool {
379        self.required
380    }
381
382    /// Renders the control required when its record-form field has no blank answer.
383    pub(crate) fn set_required(&mut self, required: bool) {
384        self.required = required;
385    }
386
387    /// Whether the bound column stores NULL, which a unique index admits any number of times.
388    pub(crate) fn is_nullable(&self) -> bool {
389        self.binding.nullable()
390    }
391
392    /// Refuses a non-empty submission the control's own rule refuses: an email field's address.
393    pub(crate) fn check(&self, value: &str) -> Option<FieldError> {
394        let value = value.trim();
395        match &self.control {
396            ControlKind::Text(text) if text.email && !value.is_empty() && !is_email(value) => {
397                Some(FieldError::invalid(
398                    self.name(),
399                    format!("{} must be a valid email", self.label_str()),
400                ))
401            }
402            _ => None,
403        }
404    }
405
406    /// Whether two submissions spell the same stored value, as the unique check compares them.
407    pub(crate) fn same_value(&self, a: &str, b: &str) -> bool {
408        match &self.control {
409            ControlKind::Text(text) => text.same_value(a, b),
410            _ => a.trim() == b.trim(),
411        }
412    }
413
414    /// The key of the field whose value narrows a dependent choice's options.
415    pub(crate) fn parent_key(&self) -> Option<&str> {
416        self.as_choice().and_then(ChoiceControl::parent_key)
417    }
418
419    /// Whether a submitted choice matches the options its parent's value `parent` offers; a
420    /// multiple choice checks only the keys its stored value `stored` does not hold.
421    pub(crate) async fn validate_exists(
422        &self,
423        cx: &Cx,
424        value: &str,
425        parent: Option<&str>,
426        stored: Option<&str>,
427    ) -> Vec<String> {
428        match &self.control {
429            ControlKind::Choice(choice) => {
430                choice
431                    .validate_exists(cx, self.label_str(), value, parent, stored)
432                    .await
433            }
434            _ => Vec::new(),
435        }
436    }
437
438    /// Re-checks a relationship choice in the write's transaction; a multiple choice re-checks
439    /// only the keys its stored value `stored` does not hold.
440    pub(crate) async fn recheck(
441        &self,
442        cx: &Cx,
443        value: &str,
444        parent: Option<&str>,
445        stored: Option<&str>,
446        ex: &mut dyn toasty::Executor,
447    ) -> Vec<String> {
448        match &self.control {
449            ControlKind::Choice(choice) => {
450                choice
451                    .recheck(cx, self.label_str(), value, parent, stored, ex)
452                    .await
453            }
454            _ => Vec::new(),
455        }
456    }
457
458    /// A multiple choice's `value` with the stored keys it drops that the user cannot view put
459    /// back, or `None` when it drops none.
460    pub(crate) async fn keep_unseen(
461        &self,
462        cx: &Cx,
463        value: &str,
464        stored: &str,
465        ex: &mut dyn toasty::Executor,
466    ) -> Option<String> {
467        match &self.control {
468            ControlKind::Choice(choice) => choice.keep_unseen(cx, value, stored, ex).await,
469            _ => None,
470        }
471    }
472
473    /// Renders the field's control alone on its page, a dependent choice with no parent value.
474    #[cfg(test)]
475    pub(crate) async fn render<'a>(
476        &self,
477        cx: &'a Cx,
478        value: Option<&str>,
479        error: Option<&str>,
480    ) -> Result<BoxView<'a>> {
481        self.render_under(cx, value, error, Placement::default())
482            .await
483    }
484
485    /// Renders the field's control where `placement` puts it.
486    pub(crate) async fn render_under<'a>(
487        &self,
488        cx: &'a Cx,
489        value: Option<&str>,
490        error: Option<&str>,
491        placement: Placement<'_>,
492    ) -> Result<BoxView<'a>> {
493        let id = placement.id(self.name());
494        match &self.control {
495            ControlKind::Text(text) => self.render_text(text, cx, value, error, id),
496            ControlKind::Choice(choice) => {
497                Box::pin(self.render_choice(choice, cx, value, error, id, placement)).await
498            }
499            ControlKind::File => self.render_file(cx, value, error, id),
500            ControlKind::Custom(control) => {
501                self.render_custom(control.as_ref(), cx, value, error, id)
502            }
503            // Its node renders it, from the keys and errors of each row's controls.
504            ControlKind::Repeater(_) => Ok(().boxed()),
505        }
506    }
507
508    /// The stored `value` as a reader sees it: a choice's option label, else the value itself.
509    /// `None` for a variant control whose value names no variant.
510    pub(crate) fn read(&self, value: &str) -> Option<String> {
511        match &self.control {
512            ControlKind::Choice(choice) => {
513                let stored = value.trim();
514                match choice.label_of(stored) {
515                    Some(label) => Some(label.to_string()),
516                    None if choice.discriminant => None,
517                    None => Some(stored.to_string()),
518                }
519            }
520            _ => Some(value.to_string()),
521        }
522    }
523
524    /// Renders the label over the stored `value`, [read](Self::read) rather than edited: an
525    /// embedded value's leaf or variant on a detail page.
526    pub(crate) fn display<'a>(&self, cx: &'a Cx, value: &str) -> BoxView<'a> {
527        match self.read(value) {
528            Some(text) => read_only(cx, self.label_str(), value_cell(cx, &text)),
529            None => ().boxed(),
530        }
531    }
532
533    /// Renders an app [`Control`] in its shared chrome.
534    fn render_custom<'a>(
535        &self,
536        control: &dyn Control,
537        cx: &'a Cx,
538        value: Option<&str>,
539        error: Option<&str>,
540        id: String,
541    ) -> Result<BoxView<'a>> {
542        let required = self.is_required();
543        let chrome = FieldChrome::new(id, error, None);
544        let input = ControlInput::new(
545            self.name(),
546            &chrome.id,
547            value,
548            required,
549            chrome.aria_invalid() == "true",
550            chrome.described_by(),
551        );
552        let rendered = control.render(cx, input);
553        render_field(
554            cx,
555            &chrome,
556            self.label_str(),
557            required,
558            attributes! {},
559            rendered,
560        )
561    }
562}
563
564/// A read-only value: `label` over `value`, which wraps as text. A detail page's entry, and an
565/// embedded leaf on one.
566pub(crate) fn read_only<'a>(cx: &'a Cx, label: &str, value: BoxView<'a>) -> BoxView<'a> {
567    let label = label.to_string();
568    view! {
569        cx =>
570        ui_field(
571            attrs: attributes! { class="ac-field" },
572            ui_field_content(
573                ui_field_title((label))
574                <div class="text-sm text-foreground wrap-anywhere whitespace-pre-wrap">
575                    (value)
576                </div>
577            )
578        )
579    }
580    .boxed()
581}
582
583/// A record's value as read-only text: a blank value reads as a muted dash.
584pub(crate) fn value_cell<'a>(cx: &'a Cx, text: &str) -> BoxView<'a> {
585    if text.trim().is_empty() {
586        return view! { cx => <span class="text-muted-foreground">"—"</span> }.boxed();
587    }
588    let text = text.to_string();
589    view! { cx => (text) }.boxed()
590}
591
592/// Where a field's control renders: under its parent's value, and in which form of its page.
593#[derive(Debug, Clone, Copy, Default)]
594pub(crate) struct Placement<'p> {
595    /// The value the field's parent posts: a dependent choice offers the rows it selects.
596    pub(crate) parent: Option<&'p str>,
597    /// The prefix of the form's DOM ids, so several forms on one page never share one; empty
598    /// for a form alone on its page.
599    pub(crate) scope: &'p str,
600    /// The URL a searchable choice fetches its options from; `None` for the resource's own
601    /// `{list}/options`, which the browser derives from the page's URL.
602    pub(crate) options: Option<&'p str>,
603}
604
605impl Placement<'_> {
606    /// The DOM id of the control posting `name`.
607    pub(crate) fn id(&self, name: &str) -> String {
608        if self.scope.is_empty() {
609            name.to_string()
610        } else {
611            format!("{}-{name}", self.scope)
612        }
613    }
614}
615
616/// The validation state a form control renders.
617pub(crate) struct FieldChrome {
618    /// The control's DOM id, which the label names.
619    pub(crate) id: String,
620    error_id: String,
621    error_text: String,
622    has_error: bool,
623}
624
625impl FieldChrome {
626    pub(crate) fn new(id: String, error: Option<&str>, fallback: Option<String>) -> Self {
627        let has_error = error.is_some() || fallback.is_some();
628        let error_text = match error {
629            Some(message) if !message.is_empty() => message.to_string(),
630            _ => fallback.unwrap_or_default(),
631        };
632        Self {
633            error_id: format!("{id}-error"),
634            id,
635            error_text,
636            has_error,
637        }
638    }
639
640    pub(crate) fn aria_invalid(&self) -> &'static str {
641        if self.has_error { "true" } else { "false" }
642    }
643
644    pub(crate) fn described_by(&self) -> Option<String> {
645        self.has_error.then(|| self.error_id.clone())
646    }
647}
648
649/// The chrome every form control renders: wrapper, label, control, and error slot.
650pub(crate) fn render_field<'a>(
651    cx: &'a Cx,
652    chrome: &FieldChrome,
653    label: &str,
654    required: bool,
655    attributes: Attributes,
656    control: BoxView<'a>,
657) -> Result<BoxView<'a>> {
658    let id = chrome.id.clone();
659    let label_text = label.to_string();
660    let has_error = chrome.has_error;
661    let error_id = chrome.error_id.clone();
662    let error_text = chrome.error_text.clone();
663    let field_class = if has_error {
664        "ac-field ac-field--error"
665    } else {
666        "ac-field"
667    };
668    Ok(view! {
669        cx =>
670        ui_field(
671            attrs: attributes! {
672                class=(field_class)
673                data-invalid=(has_error.then_some("true"))
674                (attributes)
675            },
676            ui_field_label(
677                attrs: attributes! { for=(id) },
678                (label_text)
679                if required {
680                    <span class="text-destructive" aria-hidden="true">"*"</span>
681                }
682            )
683            (control)
684            if has_error {
685                ui_field_error(
686                    attrs: attributes! { id=(error_id) class="ac-error" aria-live="polite" },
687                    (error_text)
688                )
689            }
690        )
691    }
692    .boxed())
693}
694
695#[cfg(test)]
696mod test_support {
697    pub(super) use crate::test_support::{DummyUser, cx};
698
699    /// The opening tag carrying `needle` (attributes render unordered, topcoat#122).
700    pub(super) fn tag_with<'h>(html: &'h str, needle: &str) -> &'h str {
701        let at = html
702            .find(needle)
703            .unwrap_or_else(|| panic!("no {needle} in {html}"));
704        opening_tag_at(html, html[..at].rfind('<').expect("its opening tag"))
705    }
706
707    /// Slices the opening tag at `start`, honouring quoting (topcoat#122).
708    pub(super) fn opening_tag_at(html: &str, start: usize) -> &str {
709        let mut quoted = false;
710        for (offset, byte) in html.as_bytes()[start..].iter().enumerate() {
711            match byte {
712                b'"' => quoted = !quoted,
713                b'>' if !quoted => return &html[start..start + offset],
714                _ => {}
715            }
716        }
717        panic!("unterminated tag at byte {start} in {html}");
718    }
719}
720
721#[cfg(test)]
722mod tests;