@n8n/frontend-module-inbox 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/LICENSE.md +88 -0
  2. package/README.md +59 -2
  3. package/biome.jsonc +4 -0
  4. package/eslint.config.mjs +4 -0
  5. package/package.json +62 -3
  6. package/src/__tests__/design-system-icons.test.ts +24 -0
  7. package/src/__tests__/setup.ts +4 -0
  8. package/src/components/InboxEmptyState.vue +86 -0
  9. package/src/components/InboxList.test.ts +229 -0
  10. package/src/components/InboxList.vue +195 -0
  11. package/src/components/InboxListItem.vue +134 -0
  12. package/src/components/InboxListSection.vue +168 -0
  13. package/src/composables/useReviewInboxSectionCollapse.test.ts +66 -0
  14. package/src/composables/useReviewInboxSectionCollapse.ts +42 -0
  15. package/src/inbox.api.ts +20 -0
  16. package/src/inbox.constants.ts +16 -0
  17. package/src/inbox.module.ts +50 -0
  18. package/src/inbox.routes.test.ts +111 -0
  19. package/src/inbox.routes.ts +30 -0
  20. package/src/inbox.store.test.ts +349 -0
  21. package/src/inbox.store.ts +305 -0
  22. package/src/index.ts +5 -0
  23. package/src/reviews/WorkflowReviewDetail.test.ts +647 -0
  24. package/src/reviews/WorkflowReviewDetail.vue +257 -0
  25. package/src/reviews/components/WorkflowReviewActivityFeed.test.ts +923 -0
  26. package/src/reviews/components/WorkflowReviewActivityFeed.vue +312 -0
  27. package/src/reviews/components/WorkflowReviewChangesSection.test.ts +327 -0
  28. package/src/reviews/components/WorkflowReviewChangesSection.vue +219 -0
  29. package/src/reviews/components/WorkflowReviewCommentComposer.test.ts +175 -0
  30. package/src/reviews/components/WorkflowReviewCommentComposer.vue +83 -0
  31. package/src/reviews/components/WorkflowReviewDecisionPopover.test.ts +288 -0
  32. package/src/reviews/components/WorkflowReviewDecisionPopover.vue +203 -0
  33. package/src/reviews/components/WorkflowReviewDetailMetadata.test.ts +206 -0
  34. package/src/reviews/components/WorkflowReviewDetailMetadata.vue +241 -0
  35. package/src/reviews/components/WorkflowReviewDetailTabs.decisionNote.test.ts +90 -0
  36. package/src/reviews/components/WorkflowReviewDetailTabs.test.ts +434 -0
  37. package/src/reviews/components/WorkflowReviewDetailTabs.vue +311 -0
  38. package/src/reviews/components/WorkflowReviewStatusDot.vue +67 -0
  39. package/src/reviews/components/_activity-card.scss +36 -0
  40. package/src/reviews/components/activity-entries/WorkflowReviewActivityActorAvatar.vue +50 -0
  41. package/src/reviews/components/activity-entries/WorkflowReviewActivityComment.test.ts +103 -0
  42. package/src/reviews/components/activity-entries/WorkflowReviewActivityComment.vue +116 -0
  43. package/src/reviews/components/activity-entries/WorkflowReviewActivityEventEntry.vue +361 -0
  44. package/src/reviews/components/activity-entries/WorkflowReviewActivityFallback.vue +49 -0
  45. package/src/reviews/components/activity-entries/WorkflowReviewActivityWorkflowLink.vue +36 -0
  46. package/src/reviews/components/activityEntryRegistry.ts +27 -0
  47. package/src/reviews/constants.ts +41 -0
  48. package/src/reviews/reviewActivity.store.test.ts +463 -0
  49. package/src/reviews/reviewActivity.store.ts +186 -0
  50. package/src/reviews/reviewDetail.store.test.ts +135 -0
  51. package/src/reviews/reviewDetail.store.ts +74 -0
  52. package/src/reviews/workflowReviewStatus.utils.ts +44 -0
  53. package/src/reviews/workflowReviews.api.ts +61 -0
  54. package/src/reviews/workflowReviews.utils.ts +18 -0
  55. package/src/views/InboxView.test.ts +525 -0
  56. package/src/views/InboxView.vue +251 -0
  57. package/stylelint.config.mjs +9 -0
  58. package/vite.config.ts +52 -0
@@ -0,0 +1,219 @@
1
+ <script setup lang="ts">
2
+ import type {
3
+ WorkflowReviewRequestDecision,
4
+ WorkflowReviewRequestState,
5
+ WorkflowReviewRequestWorkflowDetail,
6
+ WorkflowReviewVersionSnapshot,
7
+ } from '@n8n/api-types';
8
+ import { N8nCallout, N8nText } from '@n8n/design-system';
9
+ import { useI18n } from '@n8n/i18n';
10
+ import isEqual from 'lodash/isEqual';
11
+ import omit from 'lodash/omit';
12
+ import { computed } from 'vue';
13
+ import { componentRegistry } from '@n8n/frontend-module-sdk';
14
+
15
+ import { getVersionLabel } from '@n8n/composables/useWorkflowVersionLabel';
16
+
17
+ const WorkflowDiffView = computed(() => componentRegistry.get('workflow-diff'));
18
+
19
+ const props = defineProps<{
20
+ workflow: WorkflowReviewRequestWorkflowDetail;
21
+ state: WorkflowReviewRequestState;
22
+ decision: WorkflowReviewRequestDecision;
23
+ }>();
24
+
25
+ const i18n = useI18n();
26
+
27
+ // Only approval freezes a baseline, so an approved review diffs against what was
28
+ // published back then; a review closed any other way has nothing to diff against.
29
+ const isApproved = computed(() => props.state === 'closed' && props.decision === 'approved');
30
+ const isClosedWithoutApproval = computed(
31
+ () => props.state === 'closed' && props.decision !== 'approved',
32
+ );
33
+
34
+ /**
35
+ * Label each side the way version history does, falling back to a short id.
36
+ * Falsy rather than nullish: the publish endpoints accept `name: ""`.
37
+ */
38
+ const versionLabel = (snapshot: WorkflowReviewVersionSnapshot) =>
39
+ getVersionLabel({ workflowHistory: snapshot });
40
+
41
+ /**
42
+ * A snapshot minus its identity and metadata — i.e. everything the diff renders.
43
+ * Derived by omission rather than an explicit field list to avoid drift; renaming
44
+ * a version must not read as a change.
45
+ */
46
+ function contentOf(snapshot: WorkflowReviewVersionSnapshot) {
47
+ return omit(snapshot, ['versionId', 'name', 'createdAt']);
48
+ }
49
+
50
+ const hasChanges = computed(() => {
51
+ const { pinnedVersion, baselineVersion } = props.workflow;
52
+
53
+ if (!pinnedVersion) return false;
54
+ // No baseline means first publish: everything is a change.
55
+ if (!baselineVersion) return true;
56
+ if (pinnedVersion.versionId === baselineVersion.versionId) return false;
57
+
58
+ return !isEqual(contentOf(baselineVersion), contentOf(pinnedVersion));
59
+ });
60
+
61
+ // The dots track publish status, which approval flips: the approved version is
62
+ // the published one now, and the baseline it replaced no longer is.
63
+ const sourceDotClass = computed(() =>
64
+ isApproved.value ? 'statusDotSuperseded' : 'statusDotPublished',
65
+ );
66
+ const targetDotClass = computed(() =>
67
+ isApproved.value ? 'statusDotPublished' : 'statusDotInReview',
68
+ );
69
+
70
+ const noChangesText = computed(() =>
71
+ i18n.baseText(
72
+ isApproved.value
73
+ ? 'workflowReviews.changes.closed.noChanges'
74
+ : 'workflowReviews.changes.noChanges',
75
+ ),
76
+ );
77
+ const sourceEmptyText = computed(() =>
78
+ i18n.baseText(
79
+ isApproved.value
80
+ ? 'workflowReviews.changes.closed.firstPublish.sourceEmpty'
81
+ : 'workflowReviews.changes.firstPublish.sourceEmpty',
82
+ ),
83
+ );
84
+
85
+ const sourceLabel = computed(() =>
86
+ props.workflow.baselineVersion
87
+ ? i18n.baseText(
88
+ isApproved.value
89
+ ? 'workflowReviews.changes.closed.sourceLabel'
90
+ : 'workflowReviews.changes.sourceLabel',
91
+ { interpolate: { version: versionLabel(props.workflow.baselineVersion) } },
92
+ )
93
+ : undefined,
94
+ );
95
+ const targetLabel = computed(() =>
96
+ props.workflow.pinnedVersion
97
+ ? i18n.baseText(
98
+ isApproved.value
99
+ ? 'workflowReviews.changes.closed.targetLabel'
100
+ : 'workflowReviews.changes.targetLabel',
101
+ { interpolate: { version: versionLabel(props.workflow.pinnedVersion) } },
102
+ )
103
+ : undefined,
104
+ );
105
+ </script>
106
+
107
+ <template>
108
+ <N8nCallout
109
+ v-if="isClosedWithoutApproval"
110
+ theme="info"
111
+ :class="$style.callout"
112
+ data-test-id="workflow-review-changes-closed-without-approval"
113
+ >
114
+ {{ i18n.baseText('workflowReviews.changes.closedWithoutApproval') }}
115
+ </N8nCallout>
116
+ <N8nCallout
117
+ v-else-if="!workflow.pinnedVersion"
118
+ theme="warning"
119
+ :class="$style.callout"
120
+ data-test-id="workflow-review-changes-version-unavailable"
121
+ >
122
+ {{ i18n.baseText('workflowReviews.changes.versionUnavailable.body') }}
123
+ </N8nCallout>
124
+ <N8nCallout
125
+ v-else-if="!hasChanges"
126
+ theme="info"
127
+ :class="$style.callout"
128
+ data-test-id="workflow-review-changes-no-changes"
129
+ >
130
+ {{ noChangesText }}
131
+ </N8nCallout>
132
+ <div v-else :class="$style.diff" data-test-id="workflow-review-changes-diff">
133
+ <component
134
+ :is="WorkflowDiffView"
135
+ :source-snapshot="workflow.baselineVersion ?? undefined"
136
+ :workflow-id="workflow.workflowId"
137
+ :workflow-name="workflow.workflowName"
138
+ :target-snapshot="workflow.pinnedVersion ?? undefined"
139
+ :source-label="sourceLabel"
140
+ :target-label="targetLabel"
141
+ show-fullscreen-button
142
+ >
143
+ <!-- Only when a baseline exists: with no prior published version there is
144
+ no publish status to represent. -->
145
+ <template v-if="workflow.baselineVersion" #sourceLabel>
146
+ <span :class="$style.versionBadge" data-test-id="workflow-review-changes-source-label">
147
+ <span :class="[$style.statusDot, $style[sourceDotClass]]" />
148
+ <N8nText color="text-dark" size="small" compact>{{ sourceLabel }}</N8nText>
149
+ </span>
150
+ </template>
151
+ <template #targetLabel>
152
+ <span :class="$style.versionBadge" data-test-id="workflow-review-changes-target-label">
153
+ <span :class="[$style.statusDot, $style[targetDotClass]]" />
154
+ <N8nText color="text-dark" size="small" compact>{{ targetLabel }}</N8nText>
155
+ </span>
156
+ </template>
157
+ <template #sourceEmptyText>
158
+ <N8nText size="small" color="text-base">{{ sourceEmptyText }}</N8nText>
159
+ </template>
160
+ </component>
161
+ </div>
162
+ </template>
163
+
164
+ <style module lang="scss">
165
+ .callout {
166
+ max-width: var(--review-callout--max-width, 34rem);
167
+ margin-top: var(--spacing--5xs);
168
+ }
169
+
170
+ .versionBadge {
171
+ /* Keep the existing Reviews badge sizes when the root font changes. */
172
+ --review-version-badge--spacing: 12px;
173
+ --review-version-badge--radius: 4px;
174
+ --review-version-badge--height: 30px;
175
+ --review-version-badge--gap: 8px;
176
+ position: absolute;
177
+ top: var(--review-version-badge--spacing);
178
+ left: var(--review-version-badge--spacing);
179
+ z-index: 1;
180
+ border-radius: var(--review-version-badge--radius);
181
+ border: var(--border-width) solid var(--color--foreground--tint-1);
182
+ background: var(--color--foreground--tint-2);
183
+ display: flex;
184
+ height: var(--review-version-badge--height);
185
+ padding: 0 var(--review-version-badge--spacing);
186
+ align-items: center;
187
+ gap: var(--review-version-badge--gap);
188
+ align-self: stretch;
189
+ }
190
+
191
+ .statusDot {
192
+ height: var(--spacing--2xs);
193
+ width: var(--spacing--2xs);
194
+ border-radius: 50%;
195
+ display: inline-block;
196
+ flex-shrink: 0;
197
+ }
198
+
199
+ .statusDotPublished {
200
+ background-color: var(--color--mint-600);
201
+ }
202
+
203
+ .statusDotInReview {
204
+ background-color: var(--color--yellow-500);
205
+ }
206
+
207
+ .statusDotSuperseded {
208
+ background-color: var(--color--text--tint-1);
209
+ }
210
+
211
+ .diff {
212
+ height: 100%;
213
+ min-height: 0;
214
+ border: var(--border);
215
+ border-radius: var(--radius--2xs);
216
+ overflow: hidden;
217
+ margin-top: var(--spacing--5xs);
218
+ }
219
+ </style>
@@ -0,0 +1,175 @@
1
+ import { WORKFLOW_REVIEW_TEXT_MAX_LENGTH } from '@n8n/api-types';
2
+ import { createTestingPinia } from '@pinia/testing';
3
+ import userEvent from '@testing-library/user-event';
4
+ import { waitFor } from '@testing-library/vue';
5
+ import { createComponentRenderer, mockedStore } from '@n8n/frontend-test-utils';
6
+ import { createDeferredPromise } from '@n8n/utils/promise/deferred-promise';
7
+
8
+ import { useReviewActivityStore } from '../reviewActivity.store';
9
+ import WorkflowReviewCommentComposer from './WorkflowReviewCommentComposer.vue';
10
+
11
+ const showError = vi.fn();
12
+
13
+ vi.mock('@n8n/composables/useToast', () => ({
14
+ useToast: () => ({ showError }),
15
+ }));
16
+
17
+ /**
18
+ * The real N8nChatInput is mounted on purpose: `submitDisabled` overrides the
19
+ * component's whole internal send gate, which a stub cannot show.
20
+ */
21
+ const renderComponent = createComponentRenderer(WorkflowReviewCommentComposer);
22
+
23
+ describe('WorkflowReviewCommentComposer', () => {
24
+ let store: ReturnType<typeof mockedStore<typeof useReviewActivityStore>>;
25
+
26
+ beforeEach(() => {
27
+ createTestingPinia();
28
+ showError.mockReset();
29
+ store = mockedStore(useReviewActivityStore);
30
+ store.posting = false;
31
+ store.draft = '';
32
+ store.postComment.mockResolvedValue(true);
33
+ });
34
+
35
+ it('keeps a half-typed comment when the composer is unmounted and shown again', async () => {
36
+ // The Changes tab is a `v-if`, so it unmounts this component.
37
+ const first = renderComponent({ props: { canComment: true } });
38
+ await userEvent.type(first.getByRole('textbox'), 'half a thought');
39
+ first.unmount();
40
+
41
+ const second = renderComponent({ props: { canComment: true } });
42
+
43
+ expect(second.getByRole('textbox')).toHaveValue('half a thought');
44
+ });
45
+
46
+ it('clears a sent draft when a tab switch unmounts its composer', async () => {
47
+ const pending = createDeferredPromise<boolean>();
48
+ store.postComment.mockReturnValueOnce(pending.promise);
49
+ store.draft = 'Sent comment';
50
+ const first = renderComponent({ props: { canComment: true } });
51
+ await userEvent.click(first.getByTestId('send-message-button'));
52
+ first.unmount();
53
+ pending.resolve(true);
54
+ await waitFor(() => expect(store.draft).toBe(''));
55
+ const second = renderComponent({ props: { canComment: true } });
56
+ expect(second.getByRole('textbox')).toHaveValue('');
57
+ });
58
+
59
+ it('reports a failed post after a tab switch unmounts the composer', async () => {
60
+ const pending = createDeferredPromise<boolean>();
61
+ store.postComment.mockReturnValueOnce(pending.promise);
62
+ store.draft = 'Sent comment';
63
+ const { getByTestId, unmount } = renderComponent({ props: { canComment: true } });
64
+ await userEvent.click(getByTestId('send-message-button'));
65
+ unmount();
66
+ pending.reject(new Error('timeout'));
67
+ await pending.promise.catch(() => {});
68
+ expect(showError).toHaveBeenCalledWith(expect.any(Error), 'Could not post comment');
69
+ expect(store.draft).toBe('Sent comment');
70
+ });
71
+
72
+ it('disables the send button and the textarea when the viewer cannot comment', () => {
73
+ const { getByTestId, getByRole } = renderComponent({ props: { canComment: false } });
74
+
75
+ expect(getByTestId('send-message-button')).toBeDisabled();
76
+ expect(getByRole('textbox')).toBeDisabled();
77
+ });
78
+
79
+ it('disables only the send button while the draft is empty', () => {
80
+ const { getByTestId, getByRole } = renderComponent({ props: { canComment: true } });
81
+
82
+ expect(getByTestId('send-message-button')).toBeDisabled();
83
+ expect(getByRole('textbox')).not.toBeDisabled();
84
+ });
85
+
86
+ it('enables the send button once the draft has content', async () => {
87
+ const { getByTestId, getByRole } = renderComponent({ props: { canComment: true } });
88
+
89
+ await userEvent.type(getByRole('textbox'), 'Nice work');
90
+
91
+ await waitFor(() => expect(getByTestId('send-message-button')).not.toBeDisabled());
92
+ });
93
+
94
+ it('disables the send button once the draft goes over the length limit', async () => {
95
+ const { getByTestId, getByRole } = renderComponent({ props: { canComment: true } });
96
+ const textarea = getByRole('textbox');
97
+
98
+ textarea.focus();
99
+ await userEvent.paste('x'.repeat(WORKFLOW_REVIEW_TEXT_MAX_LENGTH));
100
+ await waitFor(() => expect(getByTestId('send-message-button')).not.toBeDisabled());
101
+ // Shift+Enter inserts the newline itself, so `maxlength` on the textarea does not stop it
102
+ await userEvent.keyboard('{Shift>}{Enter}{/Shift}');
103
+
104
+ expect(textarea).toHaveValue(`${'x'.repeat(WORKFLOW_REVIEW_TEXT_MAX_LENGTH)}\n`);
105
+ await waitFor(() => expect(getByTestId('send-message-button')).toBeDisabled());
106
+ });
107
+
108
+ it('posts the draft and clears it', async () => {
109
+ const { getByTestId, getByRole } = renderComponent({ props: { canComment: true } });
110
+ const textarea = getByRole('textbox');
111
+
112
+ await userEvent.type(textarea, 'Nice work');
113
+ await userEvent.click(getByTestId('send-message-button'));
114
+
115
+ expect(store.postComment).toHaveBeenCalledWith('Nice work');
116
+ await waitFor(() => expect(textarea).toHaveValue(''));
117
+ });
118
+
119
+ it('keeps text typed while the previous comment was still posting', async () => {
120
+ let resolvePost!: () => void;
121
+ store.postComment.mockImplementation(
122
+ async () =>
123
+ await new Promise<boolean>((resolve) => {
124
+ resolvePost = () => resolve(true);
125
+ }),
126
+ );
127
+ const { getByTestId, getByRole } = renderComponent({ props: { canComment: true } });
128
+ const textarea = getByRole('textbox');
129
+
130
+ await userEvent.type(textarea, 'Nice work');
131
+ await userEvent.click(getByTestId('send-message-button'));
132
+ // The textarea stays enabled during the request, so the user keeps typing
133
+ await userEvent.type(textarea, ' and the next one');
134
+ expect(store.postComment).toHaveBeenCalledWith('Nice work');
135
+ resolvePost();
136
+ await new Promise(setImmediate);
137
+
138
+ expect(textarea).toHaveValue('Nice work and the next one');
139
+ });
140
+
141
+ it('keeps the draft when the post it was waiting on belonged to another review', async () => {
142
+ let resolvePost!: () => void;
143
+ store.postComment.mockImplementation(
144
+ async () =>
145
+ await new Promise<boolean>((resolve) => {
146
+ // Stale: the viewer moved to another review while this was in flight.
147
+ resolvePost = () => resolve(false);
148
+ }),
149
+ );
150
+ const { getByTestId, getByRole } = renderComponent({ props: { canComment: true } });
151
+ const textarea = getByRole('textbox');
152
+
153
+ await userEvent.type(textarea, 'Nice work');
154
+ await userEvent.click(getByTestId('send-message-button'));
155
+ resolvePost();
156
+ await new Promise(setImmediate);
157
+
158
+ // Identical text, so only the stale result can keep it from being cleared.
159
+ expect(textarea).toHaveValue('Nice work');
160
+ });
161
+
162
+ it('keeps the draft and surfaces an error when posting fails', async () => {
163
+ store.postComment.mockRejectedValue(new Error('boom'));
164
+ const { getByTestId, getByRole } = renderComponent({ props: { canComment: true } });
165
+ const textarea = getByRole('textbox');
166
+
167
+ await userEvent.type(textarea, 'Nice work');
168
+ await userEvent.click(getByTestId('send-message-button'));
169
+
170
+ await waitFor(() =>
171
+ expect(showError).toHaveBeenCalledWith(expect.any(Error), 'Could not post comment'),
172
+ );
173
+ expect(textarea).toHaveValue('Nice work');
174
+ });
175
+ });
@@ -0,0 +1,83 @@
1
+ <script setup lang="ts">
2
+ import { WORKFLOW_REVIEW_TEXT_MAX_LENGTH } from '@n8n/api-types';
3
+ import { N8nChatInput } from '@n8n/design-system';
4
+ import { useI18n } from '@n8n/i18n';
5
+ import { useToast } from '@n8n/composables/useToast';
6
+ import { storeToRefs } from 'pinia';
7
+ import { computed } from 'vue';
8
+
9
+ import { useReviewActivityStore } from '../reviewActivity.store';
10
+
11
+ const props = defineProps<{ canComment: boolean }>();
12
+
13
+ const i18n = useI18n();
14
+ const { showError } = useToast();
15
+ const store = useReviewActivityStore();
16
+ const { posting, draft } = storeToRefs(store);
17
+
18
+ // The full condition, not just `posting`: N8nChatInput uses `submitDisabled ?? …`,
19
+ // so a bare `false` would replace its own empty/over-limit/disabled gate.
20
+ const submitDisabled = computed(
21
+ () =>
22
+ posting.value ||
23
+ draft.value.trim().length === 0 ||
24
+ draft.value.length > WORKFLOW_REVIEW_TEXT_MAX_LENGTH ||
25
+ !props.canComment,
26
+ );
27
+
28
+ async function onSubmit() {
29
+ const submitted = draft.value;
30
+ const body = submitted.trim();
31
+ if (!body) return;
32
+
33
+ try {
34
+ // A stale post belongs to the review the viewer left, so its completion must not
35
+ // touch the draft they are typing now, even if the two strings happen to match.
36
+ const posted = await store.postComment(body);
37
+ // Don't clear text typed while the post was in flight.
38
+ if (posted && draft.value === submitted) draft.value = '';
39
+ } catch (error) {
40
+ showError(error, i18n.baseText('workflowReviews.detail.activity.error.post'));
41
+ }
42
+ }
43
+ </script>
44
+
45
+ <template>
46
+ <!-- Implicit label: the textarea has no id to point a `for` at, and
47
+ `inheritAttrs: false` would send an aria-label to the wrapper instead. -->
48
+ <label :class="$style.composer">
49
+ <span :class="$style.srOnly">
50
+ {{ i18n.baseText('workflowReviews.detail.activity.composer.label') }}
51
+ </span>
52
+ <N8nChatInput
53
+ v-model="draft"
54
+ layout="adaptive"
55
+ :max-length="WORKFLOW_REVIEW_TEXT_MAX_LENGTH"
56
+ :placeholder="i18n.baseText('workflowReviews.detail.activity.composer.placeholder')"
57
+ refocus-after-send
58
+ :disabled="!canComment"
59
+ :disabled-tooltip="i18n.baseText('workflowReviews.detail.activity.composer.notAllowed')"
60
+ :submit-disabled="submitDisabled"
61
+ data-test-id="workflow-review-comment-composer"
62
+ @submit="onSubmit"
63
+ />
64
+ </label>
65
+ </template>
66
+
67
+ <style lang="scss" module>
68
+ .composer {
69
+ display: block;
70
+ }
71
+
72
+ .srOnly {
73
+ position: absolute;
74
+ width: 1px;
75
+ height: 1px;
76
+ padding: 0;
77
+ margin: -1px;
78
+ overflow: hidden;
79
+ clip: rect(0, 0, 0, 0);
80
+ white-space: nowrap;
81
+ border: 0;
82
+ }
83
+ </style>