Skip to content

Commit

Permalink
feat(tailwind-components): add form legend component (#4616)
Browse files Browse the repository at this point in the history
 add form legend component

* scroll to chapter
* temp code to highlight active chapter
  • Loading branch information
connoratrug authored Jan 23, 2025
1 parent aef1535 commit 11ec853
Show file tree
Hide file tree
Showing 8 changed files with 237 additions and 14 deletions.
7 changes: 7 additions & 0 deletions apps/metadata-utils/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,13 @@ export interface IFieldError {
message: string;
}

export interface IFormLegendSection {
label: string;
domId: string;
isActive?: boolean;
errorCount?: number;
}

export type columnId = string;
export type columnValue = string | number | boolean | columnValueObject;

Expand Down
3 changes: 3 additions & 0 deletions apps/tailwind-components/assets/css/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
--color-orange-500: #E1B53E;
--color-red-200: #FCECEF;
--color-red-500: #E14F62;
--color-red-700: #AE2A3F;
--color-gradient1: #0062C6;
--color-gradient2: #0072E4;

Expand Down Expand Up @@ -79,6 +80,7 @@
--background-color-input: var(--color-white);
--background-color-input-checked: var(--color-yellow-500);
--background-color-table: var(--color-white);
--background-color-notification: var(--color-red-700);


--text-color-button-primary: var(--color-white);
Expand Down Expand Up @@ -129,6 +131,7 @@
--text-color-table-column-header: var(--color-gray-600);
--text-color-form-header: var(--color-blue-800);
--text-color-input-description: var(--color-blue-800);
--text-color-legend-error-count: var(--color-white);

--text-color-invalid: var(--color-red-500);
--text-color-valid: var(--color-green-800);
Expand Down
4 changes: 3 additions & 1 deletion apps/tailwind-components/components/form/Fields.vue
Original file line number Diff line number Diff line change
Expand Up @@ -101,13 +101,15 @@ defineExpose({ validate });
<div>
<div class="first:pt-0 pt-10" v-for="chapter in chapters">
<h2
class="font-display md:text-heading-5xl text-heading-5xl text-form-header pb-8"
class="font-display md:text-heading-5xl text-heading-5xl text-form-header pb-8 scroll-mt-20"
:id="`${chapter.title}-chapter-title`"
v-if="chapter.title !== '_NO_CHAPTERS'"
>
{{ chapter.title }}
</h2>
<div class="pb-8" v-for="column in chapter.columns">
<FormField
:id="`${column.id}-form-field`"
:column="column"
:data="dataMap[column.id]"
:errors="errorMap[column.id]"
Expand Down
48 changes: 48 additions & 0 deletions apps/tailwind-components/components/form/Legend.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<template>
<nav class="pt-4 pb-8">
<h2 class="text-disabled p-4 ml-1">Jump to</h2>
<ul class="list-none space-y-3">
<li
v-for="section in sections"
class="group felx flex items-center cursor-pointer"
@click="handleGotoRequest(section)"
>
<div
class="h-[24px] w-1 group-hover:bg-button-primary"
:class="{ 'bg-button-primary': section.isActive }"
/>
<span
class="pl-4 text-title capitalize"
:class="{ 'font-bold': section.isActive }"
>{{ section.label }}</span
>
<span v-if="(section.errorCount ?? 0) > 0" class="ml-2">
<div
class="flex h-5 w-5 shrink-0 grow-0 items-center justify-center rounded-full bg-notification text-legend-error-count"
>
{{ section.errorCount }}
</div>
</span>
</li>
</ul>
</nav>
</template>

<script lang="ts" setup>
import type { IFormLegendSection } from "../../../metadata-utils/src/types";
defineProps<{
sections: IFormLegendSection[];
}>();
const emit = defineEmits(["goToSection"]);
function handleGotoRequest(section: IFormLegendSection) {
document.getElementById(`${section.domId}-chapter-title`)?.scrollIntoView({
behavior: "smooth",
block: "start",
});
emit("goToSection", section);
}
</script>
108 changes: 95 additions & 13 deletions apps/tailwind-components/pages/Form.story.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import type { FormFields } from "#build/components";
import type {
columnValue,
IColumn,
IFieldError,
ISchemaMetaData,
ITableMetaData,
Expand Down Expand Up @@ -41,7 +42,6 @@ const formFields = ref<InstanceType<typeof FormFields>>();
const formValues = ref<Record<string, columnValue>>({});
function onModelUpdate(value: Record<string, columnValue>) {
console.log("story update", value);
formValues.value = value;
}
Expand All @@ -50,25 +50,107 @@ const errors = ref<Record<string, IFieldError[]>>({});
function onErrors(newErrors: Record<string, IFieldError[]>) {
errors.value = newErrors;
}
function chapterFieldIds(chapterId: string) {
const chapterFieldIds = [];
let inChapter = false;
for (const column of tableMeta.value.columns) {
if (column.columnType === "HEADING" && column.id === chapterId) {
inChapter = true;
} else if (column.columnType === "HEADING" && column.id !== chapterId) {
inChapter = false;
} else if (inChapter) {
chapterFieldIds.push(column.id);
}
}
return chapterFieldIds;
}
function chapterErrorCount(chapterId: string) {
return chapterFieldIds(chapterId).reduce((acc, fieldId) => {
return acc + (errors.value[fieldId]?.length ?? 0);
}, 0);
}
const currentSectionDomId = ref("");
const sections = computed(() => {
return tableMeta.value?.columns
.filter((column: IColumn) => column.columnType == "HEADING")
.map((column: IColumn) => {
return {
label: column.label,
domId: column.id,
isActive: currentSectionDomId.value.startsWith(column.id),
errorCount: chapterErrorCount(column.id),
};
});
});
function setUpChapterIsInViewObserver() {
if (import.meta.client) {
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
const id = entry.target.getAttribute("id");
if (id && entry.intersectionRatio > 0) {
currentSectionDomId.value = id;
}
});
},
{
root: formFields.value?.$el,
rootMargin: "0px",
threshold: 0.5,
}
);
document.querySelectorAll("[id$=chapter-title]").forEach((section) => {
observer.observe(section);
});
}
}
onMounted(() => setUpChapterIsInViewObserver());
watch(
() => tableMeta.value,
async () => {
await nextTick();
setUpChapterIsInViewObserver();
}
);
</script>

<template>
<div class="flex flex-row">
<FormFields
v-if="tableMeta && status == 'success'"
ref="formFields"
class="basis-1/2 p-8"
:metadata="tableMeta"
:data="data"
@update:model-value="onModelUpdate"
@error="onErrors($event)"
></FormFields>

<div class="basis-1/2">
<div>Demo controls, settings and status:</div>
<div id="mock-form-contaner" class="basis-2/3 flex flex-row border">
<div class="basis-1/3">
<FormLegend
v-if="sections"
class="bg-sidebar-gradient mx-4"
:sections="sections"
/>
</div>

<FormFields
v-if="tableMeta && status === 'success'"
class="basis-2/3 p-8 border-l overflow-y-auto h-screen"
ref="formFields"
:metadata="tableMeta"
:data="data"
@update:model-value="onModelUpdate"
@error="onErrors($event)"
/>
</div>

<div class="basis-1/3 ml-2">
<h2>Demo controls, settings and status</h2>

<div class="p-4 border-2 mb-2">
<label for="table-select">Demo data</label>
<select
id="table-select"
@change="refetch()"
v-model="sampleType"
class="border-1 border-black"
Expand Down
50 changes: 50 additions & 0 deletions apps/tailwind-components/pages/Legend.story.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<template>
<div class="flex flex-row">
<div class="basis-1/2 bg-sidebar-gradient">
<FormLegend :sections="sections" @go-to-section="handleGoToRequest" />
</div>
<div class="basis-1/2 text-title p-4">
<h3>Active section: {{ sections.find((s) => s.isActive) }}</h3>
</div>
</div>
</template>

<script lang="ts" setup>
import type { IFormLegendSection } from "../../metadata-utils/src/types";
function handleGoToRequest(section: IFormLegendSection) {
sections.value.forEach((s) => {
if (s.domId === section.domId) {
s.isActive = true;
} else {
s.isActive = false;
}
});
}
const sections = ref<IFormLegendSection[]>([
{
label: "Overview",
domId: "overview",
isActive: true,
errorCount: 1,
},
{
label: "Population",
domId: "population",
errorCount: 2,
},
{
label: "Contents",
domId: "contents",
},
{
label: "Access",
domId: "access",
},
{
label: "Information",
domId: "information",
},
]);
</script>
6 changes: 6 additions & 0 deletions apps/tailwind-components/tailwind.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ module.exports = {
},
red: {
500: "#E14F62",
700: "#AE2A3F",
},
},
extend: {
Expand Down Expand Up @@ -154,6 +155,7 @@ module.exports = {
"invalid": "var(--background-color-invalid)",
"input": "var(--background-color-input)",
"table": "var(--background-color-table)",
"notification": "var(--background-color-notification)",
}),
textColor: ({ theme }) => ({
"button-primary": "var(--text-color-button-primary)",
Expand Down Expand Up @@ -206,6 +208,7 @@ module.exports = {
"valid": "var(--text-color-valid)",
"disabled": "var(--text-color-disabled)",
"required": "var(--text-color-required)",
"legend-error-count": "var(--text-color-legend-error-count)",
}),
borderColor: ({ theme }) => ({
"button-primary": "var(--border-color-button-primary)",
Expand All @@ -232,10 +235,13 @@ module.exports = {
}),
stroke: ({ theme }) => ({
"input": "var(--border-color-input)",
"notification-text": "var(--text-color-legend-error-count)",
}),
fill: ({ theme }) => ({
"input": "var(--background-color-input)",
"input-checked": "var(--background-color-input-checked)",
"notification": "var(--background-color-notification)",
"notification-text": "var(--text-color-legend-error-count)",
}),
borderRadius: {
"3px": "var(--border-radius-3px)",
Expand Down
25 changes: 25 additions & 0 deletions apps/tailwind-components/tests/components/form/renderForm.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,28 @@ test("it should handle input", async ({ page }) => {
await page.getByRole("heading", { name: "Values" }).click();
await expect(page.getByRole("definition")).toContainText("test");
});

test("it should show the chapters in the legend", async ({ page }) => {
await expect(
page.locator("span").filter({ hasText: "details" })
).toBeVisible();
await expect(page.getByText("Heading2", { exact: true })).toBeVisible();
});

test("the legend should show number of errors per chapter (if any)", async ({
page,
}) => {
await page.getByLabel("Demo data").selectOption("complex", { force: true });
// touch the form
await page.getByLabel("name", { exact: true }).click();
// skip a required field
await page.getByLabel("name", { exact: true }).press("Tab");
await expect(page.locator("span").filter({ hasText: /^2$/ })).toBeVisible();
});

test("clicking on the chapter should scroll to the chapter", async ({
page,
}) => {
await page.getByText("Heading2", { exact: true }).click();
await expect(page.getByRole("heading", { name: "heading2" })).toBeVisible();
});

0 comments on commit 11ec853

Please sign in to comment.