Add PDF View / Text View tab switcher for accessibility

- Create PdfTextToHtml heuristic parser (ALL_CAPS→h2, numbered→h3, bullets→ul, body→p)
- Create document-tabs custom element (ARIA APG tab pattern, roving tabindex, localStorage persistence)
- Register document-tabs element in application.js
- Add tab switcher to submissions/show and submit_form/show when all pages have extracted text
- Add text panel with per-page sections to both views
- Fix role="region" bug on sr-only page text divs (excess ARIA landmarks)
- Add 5 new i18n keys: pdf_view, text_view, document_view_options, text_view_disclaimer, signing_fields_below

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
pull/599/head
Marcelo Paiva 3 weeks ago
parent 778a379086
commit 929bb13f8e

@ -53,6 +53,7 @@ import GoogleDriveFilePicker from './elements/google_drive_file_picker'
import OpenModal from './elements/open_modal' import OpenModal from './elements/open_modal'
import BarChart from './elements/bar_chart' import BarChart from './elements/bar_chart'
import FieldCondition from './elements/field_condition' import FieldCondition from './elements/field_condition'
import DocumentTabs from './elements/document_tabs'
import * as TurboInstantClick from './lib/turbo_instant_click' import * as TurboInstantClick from './lib/turbo_instant_click'
@ -144,6 +145,7 @@ safeRegisterElement('google-drive-file-picker', GoogleDriveFilePicker)
safeRegisterElement('open-modal', OpenModal) safeRegisterElement('open-modal', OpenModal)
safeRegisterElement('bar-chart', BarChart) safeRegisterElement('bar-chart', BarChart)
safeRegisterElement('field-condition', FieldCondition) safeRegisterElement('field-condition', FieldCondition)
safeRegisterElement('document-tabs', DocumentTabs)
safeRegisterElement('template-builder', class extends HTMLElement { safeRegisterElement('template-builder', class extends HTMLElement {
connectedCallback () { connectedCallback () {

@ -0,0 +1,45 @@
export default class extends HTMLElement {
connectedCallback () {
this._tabs = Array.from(this.querySelectorAll('[role="tab"]'))
this._panels = Array.from(this.querySelectorAll('[role="tabpanel"]'))
this._tabs.forEach((tab) => {
tab.addEventListener('click', () => this._selectTab(tab))
tab.addEventListener('keydown', (e) => this._onKeydown(e))
})
const saved = localStorage.getItem('docuseal_document_view')
const savedTab = saved && this._tabs.find((t) => t.id === saved)
this._selectTab(savedTab || this._tabs[0], false)
}
_selectTab (selectedTab, save = true) {
this._tabs.forEach((tab) => {
const isSelected = tab === selectedTab
tab.setAttribute('aria-selected', isSelected ? 'true' : 'false')
tab.setAttribute('tabindex', isSelected ? '0' : '-1')
tab.classList.toggle('border-primary', isSelected)
tab.classList.toggle('text-primary', isSelected)
tab.classList.toggle('border-transparent', !isSelected)
tab.classList.toggle('text-base-content/60', !isSelected)
})
this._panels.forEach((panel) => {
panel.hidden = panel.id !== selectedTab.getAttribute('aria-controls')
})
if (save) localStorage.setItem('docuseal_document_view', selectedTab.id)
}
_onKeydown (e) {
const tabs = this._tabs
const idx = tabs.indexOf(e.currentTarget)
let next
if (e.key === 'ArrowRight') next = tabs[(idx + 1) % tabs.length]
else if (e.key === 'ArrowLeft') next = tabs[(idx - 1 + tabs.length) % tabs.length]
else if (e.key === 'Home') next = tabs[0]
else if (e.key === 'End') next = tabs[tabs.length - 1]
else return
e.preventDefault()
this._selectTab(next)
next.focus()
}
}

@ -18,9 +18,10 @@
<div <div
v-if="pageText" v-if="pageText"
class="sr-only" class="sr-only"
role="region"
:aria-label="`Page ${number + 1} text content`" :aria-label="`Page ${number + 1} text content`"
>{{ pageText }}</div> >
{{ pageText }}
</div>
<div <div
class="top-0 bottom-0 left-0 right-0 absolute" class="top-0 bottom-0 left-0 right-0 absolute"
@pointerdown="onStartDraw" @pointerdown="onStartDraw"

@ -90,6 +90,29 @@
<% end %> <% end %>
</div> </div>
<div id="document_view" class="w-full overflow-y-auto overflow-x-hidden mt-0.5 pt-0.5"> <div id="document_view" class="w-full overflow-y-auto overflow-x-hidden mt-0.5 pt-0.5">
<% has_full_text = schema.all? do |item|
doc = @submission.schema_documents.find { |a| a.uuid == item['attachment_uuid'] }
n_pages = doc.metadata.dig('pdf', 'number_of_pages').to_i
pages_text = doc.blob.metadata.dig('pdf', 'pages_text') || {}
n_pages > 0 && pages_text.size >= n_pages
end %>
<% if has_full_text %>
<document-tabs>
<div role="tablist" aria-label="<%= t('document_view_options') %>"
class="flex border-b border-base-300 px-0.5 mb-2">
<button role="tab" id="tab-pdf" aria-selected="true"
aria-controls="panel-pdf" tabindex="0"
class="px-4 py-2 text-sm font-medium border-b-2 -mb-px border-primary text-primary">
<%= t('pdf_view') %>
</button>
<button role="tab" id="tab-text" aria-selected="false"
aria-controls="panel-text" tabindex="-1"
class="px-4 py-2 text-sm font-medium border-b-2 -mb-px border-transparent text-base-content/60 hover:text-base-content">
<%= t('text_view') %>
</button>
</div>
<div id="panel-pdf" role="tabpanel" aria-labelledby="tab-pdf" tabindex="0">
<% end %>
<div class="pr-3.5 pl-0.5"> <div class="pr-3.5 pl-0.5">
<% fields_index = Templates.build_field_areas_index(@submission.template_fields || @submission.template.fields) %> <% fields_index = Templates.build_field_areas_index(@submission.template_fields || @submission.template.fields) %>
<% submitters_index = @submission.submitters.index_by(&:uuid) %> <% submitters_index = @submission.submitters.index_by(&:uuid) %>
@ -107,7 +130,7 @@
<page-container id="<%= "page-#{document.uuid}-#{index}" %>" class="block before:border before:absolute before:top-0 before:bottom-0 before:left-0 before:right-0 before:rounded relative mb-4" style="container-type: size; aspect-ratio: <%= width = page.metadata['width'] %> / <%= height = page.metadata['height'] %>"> <page-container id="<%= "page-#{document.uuid}-#{index}" %>" class="block before:border before:absolute before:top-0 before:bottom-0 before:left-0 before:right-0 before:rounded relative mb-4" style="container-type: size; aspect-ratio: <%= width = page.metadata['width'] %> / <%= height = page.metadata['height'] %>">
<img loading="lazy" src="<%= page.url %>" width="<%= width %>" class="rounded" height="<%= height %>" alt="<%= t('page') %> <%= index + 1 %> <%= t('of') %> <%= item['name'].presence || document.filename.base %>"> <img loading="lazy" src="<%= page.url %>" width="<%= width %>" class="rounded" height="<%= height %>" alt="<%= t('page') %> <%= index + 1 %> <%= t('of') %> <%= item['name'].presence || document.filename.base %>">
<% if (page_text = document.blob.metadata.dig('pdf', 'pages_text', index.to_s)).present? %> <% if (page_text = document.blob.metadata.dig('pdf', 'pages_text', index.to_s)).present? %>
<div class="sr-only" role="region" aria-label="<%= t('page') %> <%= index + 1 %> <%= t('text_content') %>"><%= page_text %></div> <div class="sr-only" aria-label="<%= t('page') %> <%= index + 1 %> <%= t('text_content') %>"><%= page_text %></div>
<% end %> <% end %>
<div class="top-0 bottom-0 left-0 right-0 absolute"> <div class="top-0 bottom-0 left-0 right-0 absolute">
<% document_annots_index[index]&.each do |annot| %> <% document_annots_index[index]&.each do |annot| %>
@ -146,6 +169,25 @@
<% end %> <% end %>
<% end %> <% end %>
</div> </div>
<% if has_full_text %>
</div>
<div id="panel-text" role="tabpanel" aria-labelledby="tab-text" tabindex="0" hidden>
<article class="prose max-w-none px-4 py-2">
<p class="text-sm text-base-content/60 mb-4 pb-4 border-b border-base-300 not-prose">
<%= t('text_view_disclaimer') %>
</p>
<% schema.each do |item| %>
<% doc = @submission.schema_documents.find { |a| a.uuid == item['attachment_uuid'] } %>
<% (doc.blob.metadata.dig('pdf', 'pages_text') || {}).each do |page_index, page_text| %>
<section aria-label="<%= "#{t('page')} #{page_index.to_i + 1}" %>">
<%= PdfTextToHtml.call(page_text).html_safe %>
</section>
<% end %>
<% end %>
</article>
</div>
</document-tabs>
<% end %>
</div> </div>
<div id="parties_view" class="hidden md:block relative w-full md:w-80 flex-none pt-0.5 pr-4 pl-0.5 overflow-auto space"> <div id="parties_view" class="hidden md:block relative w-full md:w-80 flex-none pt-0.5 pr-4 pl-0.5 overflow-auto space">
<% colors = %w[bg-red-500 bg-sky-500 bg-emerald-500 bg-yellow-300 bg-purple-600 bg-pink-500 bg-cyan-500 bg-orange-500 bg-lime-500 bg-indigo-500] %> <% colors = %w[bg-red-500 bg-sky-500 bg-emerald-500 bg-yellow-300 bg-purple-600 bg-pink-500 bg-cyan-500 bg-orange-500 bg-lime-500 bg-indigo-500] %>

@ -58,6 +58,29 @@
</download-button> </download-button>
</scroll-buttons> </scroll-buttons>
<% end %> <% end %>
<% has_full_text = schema.all? do |item|
doc = @submitter.submission.schema_documents.find { |a| a.uuid == item['attachment_uuid'] }
n_pages = doc.metadata.dig('pdf', 'number_of_pages').to_i
pages_text = doc.blob.metadata.dig('pdf', 'pages_text') || {}
n_pages > 0 && pages_text.size >= n_pages
end %>
<% if has_full_text %>
<document-tabs>
<div role="tablist" aria-label="<%= t('document_view_options') %>"
class="flex border-b border-base-300 mb-2 sticky top-[60px] z-40 bg-base-100 -mx-2 px-2">
<button role="tab" id="tab-pdf" aria-selected="true"
aria-controls="panel-pdf" tabindex="0"
class="px-4 py-2 text-sm font-medium border-b-2 -mb-px border-primary text-primary">
<%= t('pdf_view') %>
</button>
<button role="tab" id="tab-text" aria-selected="false"
aria-controls="panel-text" tabindex="-1"
class="px-4 py-2 text-sm font-medium border-b-2 -mb-px border-transparent text-base-content/60 hover:text-base-content">
<%= t('text_view') %>
</button>
</div>
<div id="panel-pdf" role="tabpanel" aria-labelledby="tab-pdf" tabindex="0">
<% end %>
<% schema.each do |item| %> <% schema.each do |item| %>
<% document = @submitter.submission.schema_documents.find { |a| a.uuid == item['attachment_uuid'] } %> <% document = @submitter.submission.schema_documents.find { |a| a.uuid == item['attachment_uuid'] } %>
<div id="document-<%= document.uuid %>"> <div id="document-<%= document.uuid %>">
@ -69,7 +92,7 @@
<page-container class="block relative my-4 shadow-md" style="container-type: size; aspect-ratio: <%= width = page.metadata['width'] %> / <%= height = page.metadata['height'] %>"> <page-container class="block relative my-4 shadow-md" style="container-type: size; aspect-ratio: <%= width = page.metadata['width'] %> / <%= height = page.metadata['height'] %>">
<img loading="lazy" src="<%= page.url %>" width="<%= width %>" height="<%= height %>" alt="<%= t('page') %> <%= index + 1 %> <%= t('of') %> <%= item['name'].presence || document.filename.base %>"> <img loading="lazy" src="<%= page.url %>" width="<%= width %>" height="<%= height %>" alt="<%= t('page') %> <%= index + 1 %> <%= t('of') %> <%= item['name'].presence || document.filename.base %>">
<% if (page_text = document.blob.metadata.dig('pdf', 'pages_text', index.to_s)).present? %> <% if (page_text = document.blob.metadata.dig('pdf', 'pages_text', index.to_s)).present? %>
<div class="sr-only" role="region" aria-label="<%= t('page') %> <%= index + 1 %> <%= t('text_content') %>"><%= page_text %></div> <div class="sr-only" aria-label="<%= t('page') %> <%= index + 1 %> <%= t('text_content') %>"><%= page_text %></div>
<% end %> <% end %>
<div id="page-<%= [document.uuid, index].join('-') %>" class="top-0 bottom-0 left-0 right-0 absolute"> <div id="page-<%= [document.uuid, index].join('-') %>" class="top-0 bottom-0 left-0 right-0 absolute">
<% if annots = document_annots_index[index] %> <% if annots = document_annots_index[index] %>
@ -90,6 +113,25 @@
<% end %> <% end %>
</div> </div>
<% end %> <% end %>
<% if has_full_text %>
</div>
<div id="panel-text" role="tabpanel" aria-labelledby="tab-text" tabindex="0" hidden>
<p class="text-sm text-base-content/60 p-4 border-b border-base-300">
<%= t('text_view_disclaimer') %> <%= t('signing_fields_below') %>
</p>
<article class="prose max-w-none px-4 py-2">
<% schema.each do |item| %>
<% doc = @submitter.submission.schema_documents.find { |a| a.uuid == item['attachment_uuid'] } %>
<% (doc.blob.metadata.dig('pdf', 'pages_text') || {}).each do |page_index, page_text| %>
<section aria-label="<%= "#{t('page')} #{page_index.to_i + 1}" %>">
<%= PdfTextToHtml.call(page_text).html_safe %>
</section>
<% end %>
<% end %>
</article>
</div>
</document-tabs>
<% end %>
<%= render 'shared/attribution', link_path: '/start', account: @submitter.account, with_style: false %> <%= render 'shared/attribution', link_path: '/start', account: @submitter.account, with_style: false %>
<% if @form_configs[:policy_links].present? %> <% if @form_configs[:policy_links].present? %>
<div class="text-center md:text-neutral-500 md:pr-3 md:pb-3 md:text-sm md:text-left mt-2 md:mt-0 md:fixed md:bottom-0 md:right-0"> <div class="text-center md:text-neutral-500 md:pr-3 md:pb-3 md:text-sm md:text-left mt-2 md:mt-0 md:fixed md:bottom-0 md:right-0">

@ -366,6 +366,11 @@ en: &en
page: Page page: Page
of: of of: of
text_content: text content text_content: text content
pdf_view: "PDF View"
text_view: "Text View"
document_view_options: "Document view options"
text_view_disclaimer: "Text provided for accessibility. The PDF view is the authoritative document."
signing_fields_below: "Your signing fields are in the panel below."
powered_by: Powered by powered_by: Powered by
count_documents_signed_with_html: '<b>%{count}</b> documents signed with' count_documents_signed_with_html: '<b>%{count}</b> documents signed with'
storage: Storage storage: Storage

@ -0,0 +1,60 @@
# frozen_string_literal: true
module PdfTextToHtml
module_function
def call(page_text)
output = +''
current_list = nil
page_text.split(/\r?\n/).each do |line|
stripped = line.strip
if stripped.empty?
output << close_list(current_list) if current_list
current_list = nil
next
end
current_list = process_line(stripped, output, current_list)
end
output << close_list(current_list)
output
end
def process_line(stripped, output, current_list)
if numbered_heading?(stripped)
output << close_list(current_list)
output << "<h3>#{ERB::Util.html_escape(stripped)}</h3>"
nil
elsif all_caps_heading?(stripped)
output << close_list(current_list)
output << "<h2>#{ERB::Util.html_escape(stripped)}</h2>"
nil
elsif (match = stripped.match(/\A[•*-]\s+(.+)/))
output << close_list(current_list) << '<ul>' unless current_list == :ul
output << "<li>#{ERB::Util.html_escape(match[1])}</li>"
:ul
else
output << close_list(current_list)
output << %(<p dir="auto">#{ERB::Util.html_escape(stripped)}</p>)
nil
end
end
def numbered_heading?(line)
line.length <= 80 && line.match?(/\A\d+\.\s+[A-Z]/) && !line.match?(/[.!?,;]\z/)
end
def all_caps_heading?(line)
line.length >= 3 && !line.match?(/[.!?,;]\z/) &&
line == line.upcase && line.match?(/[A-Z]/)
end
def close_list(current_list)
case current_list
when :ol then '</ol>'
when :ul then '</ul>'
else ''
end
end
end
Loading…
Cancel
Save