Skip to content

Settings ​

A settings screen is several forms that share one save. Tabs splits the fields into sections, each section is a Fieldset with its own legend, and one Form around the whole deck means a single submit carries all of them — including the sections nobody opened.

ts
import { Fieldset, Form, Switch, Tabs, TabsPanel, TabsTab, TextField } from "ropav";
Profile
Shown beside your name on anything you publish.
Notifications
Advanced
RegionWhere this workspace stores its data.
settings.vue
vue
<script setup lang="ts">
import {
  Button,
  Description,
  FieldGroup,
  Fieldset,
  FieldsetLegend,
  Form,
  Input,
  Label,
  ListBox,
  ListBoxItem,
  ListBoxItemIndicator,
  Select,
  SelectIndicator,
  SelectPopover,
  SelectTrigger,
  SelectValue,
  Switch,
  SwitchContent,
  SwitchControl,
  SwitchGroup,
  SwitchThumb,
  Tabs,
  TabsIndicator,
  TabsList,
  TabsListContainer,
  TabsPanel,
  TabsTab,
  TextArea,
  TextField,
  ToastProvider,
  ToastQueue,
} from "ropav";

const queue = new ToastQueue({ maxVisibleToasts: 3 });

const sections = [
  { id: "profile", label: "Profile" },
  { id: "notifications", label: "Notifications" },
  { id: "advanced", label: "Advanced" },
];

const alerts = [
  { isOn: true, label: "Email me on every reply", name: "replies" },
  { label: "Weekly digest", name: "digest" },
  { label: "Product announcements", name: "announcements" },
];

const regions = [
  { id: "eu", name: "Europe" },
  { id: "us", name: "United States" },
  { id: "ap", name: "Asia Pacific" },
];

const byName = (region: { name: string }) => region.name;

const onSubmit = (event: Event) => {
  event.preventDefault();

  const data = new FormData(event.target as HTMLFormElement);

  queue.add({
    description: `Submitted: ${[...data.keys()].join(", ")}.`,
    title: "Settings saved",
    variant: "success",
  });
};
</script>

<template>
  <Form class="settings" @submit="onSubmit">
    <Tabs>
      <TabsListContainer>
        <TabsList aria-label="Settings">
          <TabsTab v-for="section in sections" :id="section.id" :key="section.id">
            {{ section.label }}
            <TabsIndicator />
          </TabsTab>
        </TabsList>
      </TabsListContainer>

      <TabsPanel id="profile" class="panel" should-force-mount>
        <Fieldset>
          <FieldsetLegend>Profile</FieldsetLegend>

          <FieldGroup>
            <TextField default-value="Ada Lovelace" is-required name="name">
              <Label>Name</Label>
              <Input />
            </TextField>

            <TextField name="bio">
              <Label>Bio</Label>
              <TextArea full-width rows="3" />
              <Description>Shown beside your name on anything you publish.</Description>
            </TextField>
          </FieldGroup>
        </Fieldset>
      </TabsPanel>

      <TabsPanel id="notifications" class="panel" should-force-mount>
        <Fieldset>
          <FieldsetLegend>Notifications</FieldsetLegend>

          <SwitchGroup>
            <Switch
              v-for="alert in alerts"
              :key="alert.name"
              :default-selected="alert.isOn"
              :name="alert.name"
              value="on"
            >
              <SwitchContent>
                <SwitchControl>
                  <SwitchThumb />
                </SwitchControl>
                {{ alert.label }}
              </SwitchContent>
            </Switch>
          </SwitchGroup>
        </Fieldset>
      </TabsPanel>

      <TabsPanel id="advanced" class="panel" should-force-mount>
        <Fieldset>
          <FieldsetLegend>Advanced</FieldsetLegend>

          <FieldGroup>
            <Select
              default-value="eu"
              full-width
              :item-text-value="byName"
              :items="regions"
              name="region"
            >
              <Label>Region</Label>
              <SelectTrigger>
                <SelectValue />
                <SelectIndicator />
              </SelectTrigger>
              <SelectPopover>
                <ListBox>
                  <ListBoxItem
                    v-for="region in regions"
                    :id="region.id"
                    :key="region.id"
                    :text-value="region.name"
                  >
                    {{ region.name }}
                    <ListBoxItemIndicator />
                  </ListBoxItem>
                </ListBox>
              </SelectPopover>
              <Description>Where this workspace stores its data.</Description>
            </Select>
          </FieldGroup>
        </Fieldset>
      </TabsPanel>
    </Tabs>

    <div class="actions">
      <Button type="reset" variant="secondary">Cancel</Button>
      <Button type="submit">Save changes</Button>
    </div>

    <ToastProvider placement="bottom" :queue />
  </Form>
</template>

<style scoped>
.settings {
  max-width: var(--rp-container-md);
}

.panel {
  padding-block: calc(var(--rp-spacing) * 4);
}

/* Force-mounting keeps a panel in the layout as well as in the DOM. A hidden field still submits. */
.panel[data-inert] {
  display: none;
}

.actions {
  display: flex;
  justify-content: flex-end;
  gap: calc(var(--rp-spacing) * 3);
  padding-top: calc(var(--rp-spacing) * 4);
  border-top: 1px solid var(--rp-border);
}
</style>

A panel that unmounts takes its fields with it ​

TabsPanel renders only while its tab is selected, so a field in another tab is not in the DOM and a FormData read off the submit event never sees it. should-force-mount keeps every panel mounted; the unselected ones go inert, which keeps them out of the tab order and out of what a screen reader walks, but they are still fields in a form and still submit. Save the form above without opening a tab to see what it carried.

Mounted is not hidden ​

Force-mounting only stops the panel being destroyed — it stays in the layout, so a deck of three renders as three sections stacked down the page. Hiding the unselected ones is the screen's job, and display: none on [data-inert] is the whole of it: a hidden field is still a field, so nothing drops out of the submission.

One form around the deck, or one in each panel ​

Form sits outside Tabs here, so one submit carries all three sections and one row of actions serves them. A Form inside each panel is the other arrangement — it wants its own actions in each one, and it is what you want when a section saves to its own endpoint.

Everything is mounted at once ​

Force-mounting pays for all three panels on first render rather than on the first visit to each tab. keyboard-activation="manual" does not help with that: it changes when a tab is selected, not when a panel exists. A section expensive enough to mind about should stay unmounted with its values held in your own state instead.

Accessibility ​

  • The tab list is one stop in the tab order. Arrow keys move between the tabs, Home and End reach the ends.
  • TabsList takes an aria-label. A heading above the deck names the screen, not the set of tabs.
  • Each FieldsetLegend is read before the fields inside it, so the section name reaches a screen reader even though the tab it belongs to is doing the same job visually.