@visns-studio/visns-components 6.21.0 → 6.23.1
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.
- package/package.json +1 -1
- package/src/components/DataGrid.jsx +15 -2
- package/src/components/JsonView.jsx +299 -0
- package/src/components/Navigation.jsx +10 -1
- package/src/components/columns/SelectFilterEditor.jsx +372 -0
- package/src/components/controls/DataGridSearch.jsx +6 -1
- package/src/components/phone/ZoomPhoneBadge.jsx +625 -0
- package/src/components/phone/phonePresenceEndpoints.js +34 -0
- package/src/components/phone/phonePresenceHelpers.js +298 -0
- package/src/components/phone/useZoomPhoneLive.js +165 -0
- package/src/components/sms/SmsClientConversations.jsx +8 -0
- package/src/components/sms/SmsInbox.jsx +11 -0
- package/src/components/sms/SmsThreadPanel.jsx +258 -29
- package/src/components/sms/smsHelpers.js +37 -0
- package/src/components/styles/DataGrid.module.scss +10 -0
- package/src/components/styles/GenericDetail.module.scss +12 -2
- package/src/components/styles/JsonView.module.scss +233 -0
- package/src/components/styles/Sms.module.scss +131 -0
- package/src/components/styles/Vault.module.scss +114 -0
- package/src/components/styles/ZoomPhone.module.scss +550 -0
- package/src/components/styles/global-datagrid.css +23 -0
- package/src/components/vault/VaultAccessLog.jsx +24 -0
- package/src/components/vault/VaultEntryForm.jsx +239 -8
- package/src/components/vault/VaultManager.jsx +14 -2
- package/src/components/vault/VaultShareModal.jsx +126 -5
- package/src/components/vault/vaultShares.js +67 -3
- package/src/index.js +49 -2
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
Archive,
|
|
11
11
|
ArchiveRestore,
|
|
12
12
|
ArrowLeft,
|
|
13
|
+
CheckSquare,
|
|
13
14
|
ChevronUp,
|
|
14
15
|
ExternalLink,
|
|
15
16
|
FileText,
|
|
@@ -30,6 +31,7 @@ import useSmsLive, { defaultChannelFor } from './useSmsLive';
|
|
|
30
31
|
import useDebouncedValue from '../vault/useDebouncedValue';
|
|
31
32
|
import {
|
|
32
33
|
MAX_SEGMENTS,
|
|
34
|
+
annotationsForMessage,
|
|
33
35
|
clockTime,
|
|
34
36
|
composerPlaceholder,
|
|
35
37
|
counterLabel,
|
|
@@ -42,6 +44,7 @@ import {
|
|
|
42
44
|
nextPlaceholder,
|
|
43
45
|
normaliseNumberForDisplay,
|
|
44
46
|
prependMessages,
|
|
47
|
+
resolveAnnotations,
|
|
45
48
|
segmentCount,
|
|
46
49
|
statusLabel,
|
|
47
50
|
threadDisplayName,
|
|
@@ -83,6 +86,13 @@ let temporaryId = 0;
|
|
|
83
86
|
* subscription, in which case the parent passes `subscribe={false}` and feeds
|
|
84
87
|
* it events. Two subscriptions to one channel is not an error, but it is two
|
|
85
88
|
* websocket authorisations and two copies of every message to reconcile.
|
|
89
|
+
*
|
|
90
|
+
* `messageSelection` and `messageAnnotations` are the two hooks a host has into
|
|
91
|
+
* the timeline. Both are opt-in and both are deliberately nameless about what
|
|
92
|
+
* the host does with them: this module knows about messages, and an application
|
|
93
|
+
* that wants to file them against something of its own (a task, a complaint, a
|
|
94
|
+
* file note) supplies the verb and the badges. Absent, the panel behaves
|
|
95
|
+
* exactly as it did before they existed.
|
|
86
96
|
*/
|
|
87
97
|
const SmsThreadPanel = ({
|
|
88
98
|
threadId,
|
|
@@ -107,6 +117,23 @@ const SmsThreadPanel = ({
|
|
|
107
117
|
lines = null,
|
|
108
118
|
canManage = false,
|
|
109
119
|
clientUrl = (id) => `/clients/${id}`,
|
|
120
|
+
// `{label, onSelect}` — turns on a Select mode over the timeline. `label` is
|
|
121
|
+
// the verb printed on the confirm button ("Attach to task…"), `onSelect` is
|
|
122
|
+
// called as `onSelect(messages, thread)` and may return a promise; the mode
|
|
123
|
+
// closes when it settles.
|
|
124
|
+
//
|
|
125
|
+
// The thread is the SECOND argument, so a host that already knows which
|
|
126
|
+
// conversation it mounted (a client's own page) can ignore it. A host that
|
|
127
|
+
// does not — an inbox, where the pane chooses the thread — has no other way
|
|
128
|
+
// to know which one is open or which client it is linked to.
|
|
129
|
+
messageSelection = null,
|
|
130
|
+
// `{[messageId]: [{id, label, onClick}]}` — small chips under the matching
|
|
131
|
+
// bubble. The host owns what they say and what a click does.
|
|
132
|
+
//
|
|
133
|
+
// May also be a function `(thread) => map`, for the same reason: chips in an
|
|
134
|
+
// inbox depend on which conversation is open, and no host can hold one map
|
|
135
|
+
// for every thread in the practice.
|
|
136
|
+
messageAnnotations = null,
|
|
110
137
|
onThreadChange,
|
|
111
138
|
onMessage,
|
|
112
139
|
onBack = null,
|
|
@@ -130,6 +157,10 @@ const SmsThreadPanel = ({
|
|
|
130
157
|
const [linkOpen, setLinkOpen] = useState(false);
|
|
131
158
|
const [simulateOpen, setSimulateOpen] = useState(false);
|
|
132
159
|
|
|
160
|
+
const [selecting, setSelecting] = useState(false);
|
|
161
|
+
const [selectedIds, setSelectedIds] = useState([]);
|
|
162
|
+
const [confirmingSelection, setConfirmingSelection] = useState(false);
|
|
163
|
+
|
|
133
164
|
const mountedRef = useRef(true);
|
|
134
165
|
const timelineRef = useRef(null);
|
|
135
166
|
const composerRef = useRef(null);
|
|
@@ -445,6 +476,97 @@ const SmsThreadPanel = ({
|
|
|
445
476
|
[enterSends, submit]
|
|
446
477
|
);
|
|
447
478
|
|
|
479
|
+
/* ------------------------------------------------------------ selection */
|
|
480
|
+
|
|
481
|
+
const selectionEnabled =
|
|
482
|
+
Boolean(messageSelection) && typeof messageSelection.onSelect === 'function';
|
|
483
|
+
|
|
484
|
+
// Moving to another conversation abandons whatever was ticked in the last
|
|
485
|
+
// one: the selection is a list of ids from a timeline that is no longer on
|
|
486
|
+
// screen, and carrying it across would file one client's messages from
|
|
487
|
+
// another client's page.
|
|
488
|
+
useEffect(() => {
|
|
489
|
+
setSelecting(false);
|
|
490
|
+
setSelectedIds([]);
|
|
491
|
+
}, [threadId]);
|
|
492
|
+
|
|
493
|
+
// Withdrawing the prop has to close the mode with it, or a host that turns
|
|
494
|
+
// selection off mid-render leaves the bar stranded with no way out.
|
|
495
|
+
useEffect(() => {
|
|
496
|
+
if (!selectionEnabled) {
|
|
497
|
+
setSelecting(false);
|
|
498
|
+
setSelectedIds([]);
|
|
499
|
+
}
|
|
500
|
+
}, [selectionEnabled]);
|
|
501
|
+
|
|
502
|
+
const isSelected = useCallback(
|
|
503
|
+
(id) => selectedIds.some((one) => String(one) === String(id)),
|
|
504
|
+
[selectedIds]
|
|
505
|
+
);
|
|
506
|
+
|
|
507
|
+
const toggleSelected = useCallback((id) => {
|
|
508
|
+
setSelectedIds((prev) =>
|
|
509
|
+
prev.some((one) => String(one) === String(id))
|
|
510
|
+
? prev.filter((one) => String(one) !== String(id))
|
|
511
|
+
: [...prev, id]
|
|
512
|
+
);
|
|
513
|
+
}, []);
|
|
514
|
+
|
|
515
|
+
const cancelSelection = useCallback(() => {
|
|
516
|
+
setSelecting(false);
|
|
517
|
+
setSelectedIds([]);
|
|
518
|
+
}, []);
|
|
519
|
+
|
|
520
|
+
const confirmSelection = useCallback(() => {
|
|
521
|
+
if (!selectionEnabled || selectedIds.length === 0 || confirmingSelection) return;
|
|
522
|
+
|
|
523
|
+
// The host is handed the message objects, not the ids: it should not
|
|
524
|
+
// have to hold a second copy of the timeline to know what it was given.
|
|
525
|
+
// Timeline order, not tick order — a conversation extract only reads
|
|
526
|
+
// correctly in the order it happened.
|
|
527
|
+
const chosen = messages.filter((one) => isSelected(one.id));
|
|
528
|
+
|
|
529
|
+
setConfirmingSelection(true);
|
|
530
|
+
|
|
531
|
+
Promise.resolve(messageSelection.onSelect(chosen, thread))
|
|
532
|
+
.then(() => {
|
|
533
|
+
if (!mountedRef.current) return;
|
|
534
|
+
|
|
535
|
+
setSelecting(false);
|
|
536
|
+
setSelectedIds([]);
|
|
537
|
+
})
|
|
538
|
+
.catch(() => {
|
|
539
|
+
// A failed hand-off leaves the ticks where they are so the user
|
|
540
|
+
// can try again; the host is the one that says what went wrong.
|
|
541
|
+
})
|
|
542
|
+
.then(() => {
|
|
543
|
+
if (mountedRef.current) setConfirmingSelection(false);
|
|
544
|
+
});
|
|
545
|
+
}, [
|
|
546
|
+
selectionEnabled,
|
|
547
|
+
selectedIds,
|
|
548
|
+
confirmingSelection,
|
|
549
|
+
messages,
|
|
550
|
+
isSelected,
|
|
551
|
+
messageSelection,
|
|
552
|
+
thread,
|
|
553
|
+
]);
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* The chip map for the conversation on screen. A function form is called
|
|
557
|
+
* with the whole thread rather than its id, because what a host looks chips
|
|
558
|
+
* up by is usually the client on it.
|
|
559
|
+
*/
|
|
560
|
+
const annotations = useMemo(
|
|
561
|
+
() => resolveAnnotations(messageAnnotations, thread),
|
|
562
|
+
[messageAnnotations, thread]
|
|
563
|
+
);
|
|
564
|
+
|
|
565
|
+
const annotationsFor = useCallback(
|
|
566
|
+
(id) => annotationsForMessage(annotations, id),
|
|
567
|
+
[annotations]
|
|
568
|
+
);
|
|
569
|
+
|
|
448
570
|
/* -------------------------------------------------------------- actions */
|
|
449
571
|
|
|
450
572
|
const patchThread = useCallback(
|
|
@@ -589,6 +711,44 @@ const SmsThreadPanel = ({
|
|
|
589
711
|
const attachments = Array.isArray(message.attachments) ? message.attachments : [];
|
|
590
712
|
// A failure is never silent, whatever else the run is doing.
|
|
591
713
|
const showMeta = lastOfRun || failed || heldMessage;
|
|
714
|
+
const chips = annotationsFor(message.id);
|
|
715
|
+
// A message still on its way to the server has a temporary id, so there
|
|
716
|
+
// is nothing a host could file it against yet.
|
|
717
|
+
const tickable = selecting && !message.pending;
|
|
718
|
+
const ticked = tickable && isSelected(message.id);
|
|
719
|
+
|
|
720
|
+
const bubble = (
|
|
721
|
+
<div
|
|
722
|
+
className={[
|
|
723
|
+
styles.bubble,
|
|
724
|
+
outgoing ? styles.bubbleBodyOut : styles.bubbleBodyIn,
|
|
725
|
+
heldMessage ? styles.bubbleHeld : '',
|
|
726
|
+
failed ? styles.bubbleFailed : '',
|
|
727
|
+
message.pending ? styles.bubblePending : '',
|
|
728
|
+
]
|
|
729
|
+
.filter(Boolean)
|
|
730
|
+
.join(' ')}
|
|
731
|
+
>
|
|
732
|
+
{message.body}
|
|
733
|
+
|
|
734
|
+
{attachments.length > 0 && (
|
|
735
|
+
<div className={styles.attachments}>
|
|
736
|
+
{attachments.map((file) => (
|
|
737
|
+
<a
|
|
738
|
+
key={file.id}
|
|
739
|
+
className={styles.attachment}
|
|
740
|
+
href={file.download_url}
|
|
741
|
+
target="_blank"
|
|
742
|
+
rel="noopener noreferrer"
|
|
743
|
+
>
|
|
744
|
+
<FileText size={12} strokeWidth={2} aria-hidden="true" />
|
|
745
|
+
<span>{file.name}</span>
|
|
746
|
+
</a>
|
|
747
|
+
))}
|
|
748
|
+
</div>
|
|
749
|
+
)}
|
|
750
|
+
</div>
|
|
751
|
+
);
|
|
592
752
|
|
|
593
753
|
return (
|
|
594
754
|
<div
|
|
@@ -601,36 +761,52 @@ const SmsThreadPanel = ({
|
|
|
601
761
|
.filter(Boolean)
|
|
602
762
|
.join(' ')}
|
|
603
763
|
>
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
{
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
764
|
+
{selecting ? (
|
|
765
|
+
// The whole row is the hit target while selecting: ticking
|
|
766
|
+
// a message by aiming at a 14px box is not how anybody
|
|
767
|
+
// picks three texts out of a conversation.
|
|
768
|
+
<label
|
|
769
|
+
className={[
|
|
770
|
+
styles.pickRow,
|
|
771
|
+
ticked ? styles.pickRowOn : '',
|
|
772
|
+
message.pending ? styles.pickRowOff : '',
|
|
773
|
+
]
|
|
774
|
+
.filter(Boolean)
|
|
775
|
+
.join(' ')}
|
|
776
|
+
>
|
|
777
|
+
<input
|
|
778
|
+
type="checkbox"
|
|
779
|
+
className={styles.pickBox}
|
|
780
|
+
checked={ticked}
|
|
781
|
+
disabled={!tickable}
|
|
782
|
+
onChange={() => toggleSelected(message.id)}
|
|
783
|
+
/>
|
|
784
|
+
{bubble}
|
|
785
|
+
</label>
|
|
786
|
+
) : (
|
|
787
|
+
bubble
|
|
788
|
+
)}
|
|
789
|
+
|
|
790
|
+
{chips.length > 0 && (
|
|
791
|
+
<div className={styles.annotations}>
|
|
792
|
+
{chips.map((annotation) =>
|
|
793
|
+
typeof annotation.onClick === 'function' ? (
|
|
794
|
+
<button
|
|
795
|
+
key={annotation.id}
|
|
796
|
+
type="button"
|
|
797
|
+
className={`${styles.annotation} ${styles.annotationAction}`}
|
|
798
|
+
onClick={() => annotation.onClick(annotation)}
|
|
626
799
|
>
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
800
|
+
{annotation.label}
|
|
801
|
+
</button>
|
|
802
|
+
) : (
|
|
803
|
+
<span key={annotation.id} className={styles.annotation}>
|
|
804
|
+
{annotation.label}
|
|
805
|
+
</span>
|
|
806
|
+
)
|
|
807
|
+
)}
|
|
808
|
+
</div>
|
|
809
|
+
)}
|
|
634
810
|
|
|
635
811
|
{showMeta && (
|
|
636
812
|
<div
|
|
@@ -703,6 +879,22 @@ const SmsThreadPanel = ({
|
|
|
703
879
|
</a>
|
|
704
880
|
) : null}
|
|
705
881
|
|
|
882
|
+
{selectionEnabled && (
|
|
883
|
+
<button
|
|
884
|
+
type="button"
|
|
885
|
+
className={`${styles.ghostButton} ${
|
|
886
|
+
selecting ? styles.ghostButtonOn : ''
|
|
887
|
+
}`.trim()}
|
|
888
|
+
aria-pressed={selecting}
|
|
889
|
+
onClick={() =>
|
|
890
|
+
selecting ? cancelSelection() : setSelecting(true)
|
|
891
|
+
}
|
|
892
|
+
>
|
|
893
|
+
<CheckSquare size={14} strokeWidth={2} aria-hidden="true" />
|
|
894
|
+
{selecting ? 'Done' : 'Select'}
|
|
895
|
+
</button>
|
|
896
|
+
)}
|
|
897
|
+
|
|
706
898
|
<button
|
|
707
899
|
type="button"
|
|
708
900
|
className={styles.ghostButton}
|
|
@@ -797,6 +989,42 @@ const SmsThreadPanel = ({
|
|
|
797
989
|
))}
|
|
798
990
|
</div>
|
|
799
991
|
|
|
992
|
+
{/*
|
|
993
|
+
* While selecting, the action bar stands where the composer does.
|
|
994
|
+
* Both at once would be a pane with two footers and two primary
|
|
995
|
+
* buttons, and picking messages out of a conversation is not
|
|
996
|
+
* something anybody does halfway through writing a reply.
|
|
997
|
+
*/}
|
|
998
|
+
{selecting ? (
|
|
999
|
+
<div className={styles.selectBar}>
|
|
1000
|
+
<span className={styles.selectCount}>
|
|
1001
|
+
{selectedIds.length === 0
|
|
1002
|
+
? 'Pick the messages you want'
|
|
1003
|
+
: `${selectedIds.length} selected`}
|
|
1004
|
+
</span>
|
|
1005
|
+
|
|
1006
|
+
<div className={styles.selectActions}>
|
|
1007
|
+
<button
|
|
1008
|
+
type="button"
|
|
1009
|
+
className={styles.ghostButton}
|
|
1010
|
+
onClick={cancelSelection}
|
|
1011
|
+
>
|
|
1012
|
+
Cancel
|
|
1013
|
+
</button>
|
|
1014
|
+
<button
|
|
1015
|
+
type="button"
|
|
1016
|
+
className={styles.primaryButton}
|
|
1017
|
+
onClick={confirmSelection}
|
|
1018
|
+
disabled={selectedIds.length === 0 || confirmingSelection}
|
|
1019
|
+
>
|
|
1020
|
+
{confirmingSelection && (
|
|
1021
|
+
<Loader2 size={14} className={styles.spin} aria-hidden="true" />
|
|
1022
|
+
)}
|
|
1023
|
+
{messageSelection.label || 'Select'} ({selectedIds.length})
|
|
1024
|
+
</button>
|
|
1025
|
+
</div>
|
|
1026
|
+
</div>
|
|
1027
|
+
) : (
|
|
800
1028
|
<div className={styles.composer}>
|
|
801
1029
|
<div className={styles.composerBox}>
|
|
802
1030
|
<textarea
|
|
@@ -905,6 +1133,7 @@ const SmsThreadPanel = ({
|
|
|
905
1133
|
</div>
|
|
906
1134
|
</div>
|
|
907
1135
|
</div>
|
|
1136
|
+
)}
|
|
908
1137
|
|
|
909
1138
|
<LinkClientModal
|
|
910
1139
|
open={linkOpen}
|
|
@@ -1202,7 +1202,43 @@ export const describeRecipient = (input) => {
|
|
|
1202
1202
|
};
|
|
1203
1203
|
};
|
|
1204
1204
|
|
|
1205
|
+
/* -------------------------------------------------------------------------- *
|
|
1206
|
+
* Host annotations
|
|
1207
|
+
*
|
|
1208
|
+
* A host may hand the conversation pane chips to draw under particular
|
|
1209
|
+
* messages. Two shapes, because two kinds of host need it:
|
|
1210
|
+
*
|
|
1211
|
+
* - a MAP, `{[messageId]: [chip]}`, for a screen that already knows which
|
|
1212
|
+
* conversation it mounted — a client's own page;
|
|
1213
|
+
* - a FUNCTION of the open thread, for a screen that does not — an inbox,
|
|
1214
|
+
* where the pane chooses the thread and no host could hold one map for
|
|
1215
|
+
* every conversation in the practice.
|
|
1216
|
+
*
|
|
1217
|
+
* Both live here rather than inside the component so the shape has a test.
|
|
1218
|
+
* -------------------------------------------------------------------------- */
|
|
1219
|
+
|
|
1220
|
+
/** The chip map for one conversation, whichever shape the host passed. */
|
|
1221
|
+
export const resolveAnnotations = (annotations, thread) => {
|
|
1222
|
+
const resolved =
|
|
1223
|
+
typeof annotations === 'function' ? annotations(thread) : annotations;
|
|
1224
|
+
|
|
1225
|
+
return resolved && typeof resolved === 'object' ? resolved : null;
|
|
1226
|
+
};
|
|
1227
|
+
|
|
1228
|
+
/**
|
|
1229
|
+
* The chips for one message. Looked up by the id as given AND as a string: a
|
|
1230
|
+
* map parsed out of JSON has string keys, and the timeline's ids are numbers.
|
|
1231
|
+
*/
|
|
1232
|
+
export const annotationsForMessage = (annotations, id) => {
|
|
1233
|
+
if (!annotations) return [];
|
|
1234
|
+
|
|
1235
|
+
const found = annotations[id] ?? annotations[String(id)];
|
|
1236
|
+
|
|
1237
|
+
return Array.isArray(found) ? found : [];
|
|
1238
|
+
};
|
|
1239
|
+
|
|
1205
1240
|
export default {
|
|
1241
|
+
annotationsForMessage,
|
|
1206
1242
|
badgeCount,
|
|
1207
1243
|
clientRecipient,
|
|
1208
1244
|
clockTime,
|
|
@@ -1229,6 +1265,7 @@ export default {
|
|
|
1229
1265
|
prependMessages,
|
|
1230
1266
|
RECIPIENT_HINTS,
|
|
1231
1267
|
relativeTime,
|
|
1268
|
+
resolveAnnotations,
|
|
1232
1269
|
segmentCount,
|
|
1233
1270
|
smsHref,
|
|
1234
1271
|
splitPersonName,
|
|
@@ -697,6 +697,16 @@
|
|
|
697
697
|
width: 100%;
|
|
698
698
|
font-size: 0.9rem;
|
|
699
699
|
box-sizing: border-box;
|
|
700
|
+
// The input is type="search" (to sidestep host apps' global text-input
|
|
701
|
+
// rules), so strip WebKit's search decorations to keep it looking like
|
|
702
|
+
// the plain field it always was.
|
|
703
|
+
appearance: none;
|
|
704
|
+
-webkit-appearance: none;
|
|
705
|
+
|
|
706
|
+
&::-webkit-search-decoration,
|
|
707
|
+
&::-webkit-search-cancel-button {
|
|
708
|
+
-webkit-appearance: none;
|
|
709
|
+
}
|
|
700
710
|
|
|
701
711
|
&:focus {
|
|
702
712
|
outline: none;
|
|
@@ -1362,8 +1362,12 @@
|
|
|
1362
1362
|
/* It stays pinned bottom-right — every detail page in every project is laid
|
|
1363
1363
|
out around that — but it had no ground of its own, so on a short page the
|
|
1364
1364
|
buttons floated over the page background with nothing behind them. A pill
|
|
1365
|
-
with the same elevation as the panels gives them somewhere to sit.
|
|
1366
|
-
|
|
1365
|
+
with the same elevation as the panels gives them somewhere to sit.
|
|
1366
|
+
|
|
1367
|
+
ONLY when there is more than one of them: a tray around a single solid
|
|
1368
|
+
button reads as a white oval drawn around it, not as ground. A lone
|
|
1369
|
+
button carries its own elevation instead. */
|
|
1370
|
+
.polActions:has(> :nth-child(2)) {
|
|
1367
1371
|
padding: 0.4rem;
|
|
1368
1372
|
border: 1px solid var(--gd-line);
|
|
1369
1373
|
border-radius: var(--radius-pill, 999px);
|
|
@@ -1374,6 +1378,12 @@
|
|
|
1374
1378
|
0 16px 32px -14px color-mix(in srgb, var(--primary-color, #1b3933) 30%, transparent);
|
|
1375
1379
|
}
|
|
1376
1380
|
|
|
1381
|
+
.polActions:not(:has(> :nth-child(2))) > * {
|
|
1382
|
+
box-shadow:
|
|
1383
|
+
0 2px 4px color-mix(in srgb, var(--primary-color, #1b3933) 12%, transparent),
|
|
1384
|
+
0 12px 24px -12px color-mix(in srgb, var(--primary-color, #1b3933) 35%, transparent);
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1377
1387
|
/* -- states --------------------------------------------------------------- */
|
|
1378
1388
|
|
|
1379
1389
|
.noDataMessage {
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
@use 'surface' as *;
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* JsonView — a parsed JSON value drawn as a collapsible tree.
|
|
5
|
+
*
|
|
6
|
+
* The palette is the page-primitive one (`_surface.scss`): two levels, so a
|
|
7
|
+
* project that defines nothing gets something legible and a page that already
|
|
8
|
+
* has a palette sets `--vs-*` once on its own root. Nothing here carries a
|
|
9
|
+
* colour of its own except the four type tints, and even those are mixed
|
|
10
|
+
* against the app's body ink rather than declared outright — see below.
|
|
11
|
+
*
|
|
12
|
+
* Sizing is the CALLER's job. The root has no height, no scroll and no border:
|
|
13
|
+
* a tree inside a modal wants a capped scroll region, the same tree on a page
|
|
14
|
+
* wants to be as tall as it is, and a component that decides that itself is one
|
|
15
|
+
* the next screen has to fight.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
$jv-mono: 'SFMono-Regular', 'Menlo', 'Consolas', monospace;
|
|
19
|
+
|
|
20
|
+
.root {
|
|
21
|
+
@include surface-tokens;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The type tints, and why each is a MIX rather than a hex value.
|
|
25
|
+
*
|
|
26
|
+
* A JSON viewer needs four inks that are distinguishable from each other
|
|
27
|
+
* and readable on the page behind them — and this library does not know
|
|
28
|
+
* which page that is. A fixed `#1f8b4c` is a fine green on white and an
|
|
29
|
+
* unreadable one on a dark surface.
|
|
30
|
+
*
|
|
31
|
+
* Mixing the hue with the app's own body ink solves both directions at
|
|
32
|
+
* once: on a light theme the ink is near-black, so the hue darkens and
|
|
33
|
+
* gains contrast against white; on a dark theme the ink is near-white, so
|
|
34
|
+
* the SAME declaration lightens it against the dark ground. The hue stays
|
|
35
|
+
* recognisable either way because it is three quarters of the mix.
|
|
36
|
+
*/
|
|
37
|
+
--jv-key: var(--vs-json-key, color-mix(in srgb, var(--pr-heading) 80%, var(--pr-ink)));
|
|
38
|
+
--jv-string: var(--vs-json-string, color-mix(in srgb, #1f8b4c 74%, var(--pr-ink)));
|
|
39
|
+
--jv-number: var(--vs-json-number, color-mix(in srgb, #2f6fd0 74%, var(--pr-ink)));
|
|
40
|
+
--jv-boolean: var(
|
|
41
|
+
--vs-json-boolean,
|
|
42
|
+
color-mix(in srgb, #8a5cd0 74%, var(--pr-ink))
|
|
43
|
+
);
|
|
44
|
+
// The indent guide is a hairline that must read as structure and not as a
|
|
45
|
+
// border; the page's own line colour is too heavy repeated every level.
|
|
46
|
+
--jv-guide: var(--vs-json-guide, color-mix(in srgb, var(--pr-ink) 16%, transparent));
|
|
47
|
+
|
|
48
|
+
color: var(--pr-ink);
|
|
49
|
+
font-family: $jv-mono;
|
|
50
|
+
// Small and tight: the value of this view over a textarea is how much of
|
|
51
|
+
// the document fits on one screen.
|
|
52
|
+
font-size: 0.76rem;
|
|
53
|
+
line-height: 1.45;
|
|
54
|
+
text-align: left;
|
|
55
|
+
// A long unbroken token — a URL, a base64 blob — must wrap rather than
|
|
56
|
+
// push a horizontal scrollbar under the whole tree.
|
|
57
|
+
overflow-wrap: anywhere;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/* The size is declared on the elements as well as the root: inherited
|
|
61
|
+
font-size loses to ANY rule that names the element directly, however
|
|
62
|
+
specific the ancestor — and this tree mounts inside host apps whose global
|
|
63
|
+
stylesheets do exactly that to li, span and button. Restating `inherit`
|
|
64
|
+
here puts a declaration on every element, outranking a host's bare
|
|
65
|
+
element rules (0-0-1 vs this 0-1-0). `:where()` keeps the inner selector
|
|
66
|
+
at zero so the file's own later, smaller sizes (`.more`) still win. */
|
|
67
|
+
.root :where(ul, li, span, button) {
|
|
68
|
+
font-family: inherit;
|
|
69
|
+
font-size: inherit;
|
|
70
|
+
line-height: inherit;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
.list {
|
|
74
|
+
padding: 0;
|
|
75
|
+
margin: 0;
|
|
76
|
+
list-style: none;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/* Each level is indented by its own guide line, so the depth is readable
|
|
80
|
+
without counting spaces. */
|
|
81
|
+
.children {
|
|
82
|
+
padding-left: 0.7rem;
|
|
83
|
+
margin-left: 0.28rem;
|
|
84
|
+
border-left: 1px solid var(--jv-guide);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
.row {
|
|
88
|
+
// The host CRM styles bare list items in places; nothing here inherits
|
|
89
|
+
// anything it needs. Same reasoning as Vault.module.scss's header.
|
|
90
|
+
padding: 0;
|
|
91
|
+
margin: 0;
|
|
92
|
+
list-style: none;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
.line {
|
|
96
|
+
display: flex;
|
|
97
|
+
gap: 0.3rem;
|
|
98
|
+
align-items: flex-start;
|
|
99
|
+
min-width: 0;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/* Sits under the caret of a node that has one, so keys line up whether or not
|
|
103
|
+
the row is expandable. */
|
|
104
|
+
.spacer {
|
|
105
|
+
flex: 0 0 0.85rem;
|
|
106
|
+
width: 0.85rem;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
.toggle {
|
|
110
|
+
display: inline-flex;
|
|
111
|
+
gap: 0.3rem;
|
|
112
|
+
align-items: flex-start;
|
|
113
|
+
box-sizing: border-box;
|
|
114
|
+
width: auto;
|
|
115
|
+
min-width: 0;
|
|
116
|
+
height: auto;
|
|
117
|
+
min-height: 0;
|
|
118
|
+
padding: 0 0.15rem 0 0;
|
|
119
|
+
margin: 0;
|
|
120
|
+
color: inherit;
|
|
121
|
+
font: inherit;
|
|
122
|
+
text-align: left;
|
|
123
|
+
text-transform: none;
|
|
124
|
+
letter-spacing: normal;
|
|
125
|
+
background: none;
|
|
126
|
+
border: 0;
|
|
127
|
+
border-radius: var(--pr-radius, 6px);
|
|
128
|
+
box-shadow: none;
|
|
129
|
+
cursor: pointer;
|
|
130
|
+
appearance: none;
|
|
131
|
+
|
|
132
|
+
&:hover {
|
|
133
|
+
background: var(--pr-quiet);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
&:focus-visible {
|
|
137
|
+
outline: 2px solid var(--pr-accent);
|
|
138
|
+
outline-offset: 1px;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
.caret {
|
|
143
|
+
flex: 0 0 0.85rem;
|
|
144
|
+
width: 0.85rem;
|
|
145
|
+
color: var(--pr-muted);
|
|
146
|
+
/* The two glyphs have different optical widths; a fixed box stops the key
|
|
147
|
+
beside them shifting by a pixel on every open and close. */
|
|
148
|
+
text-align: left;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
.key {
|
|
152
|
+
color: var(--jv-key);
|
|
153
|
+
font-weight: 600;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/* An array index is not a name, and painting it like one makes a long list look
|
|
157
|
+
like a hundred different keys. */
|
|
158
|
+
.index {
|
|
159
|
+
color: var(--pr-muted);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
.colon {
|
|
163
|
+
color: var(--pr-muted);
|
|
164
|
+
font-weight: 400;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/* `{…} 5 keys`, and the brackets of an open node. */
|
|
168
|
+
.summary {
|
|
169
|
+
color: var(--pr-muted);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
.valueCell {
|
|
173
|
+
display: inline-flex;
|
|
174
|
+
flex-wrap: wrap;
|
|
175
|
+
gap: 0.35rem;
|
|
176
|
+
align-items: baseline;
|
|
177
|
+
min-width: 0;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
.string {
|
|
181
|
+
color: var(--jv-string);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/* The expanded form of a multi-line string. This is where the vault's
|
|
185
|
+
credentials actually live — a `content` field with embedded newlines — so the
|
|
186
|
+
line breaks are the content, not whitespace to be collapsed. */
|
|
187
|
+
.stringOpen {
|
|
188
|
+
display: block;
|
|
189
|
+
white-space: pre-wrap;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
.number {
|
|
193
|
+
color: var(--jv-number);
|
|
194
|
+
font-variant-numeric: tabular-nums;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
.boolean {
|
|
198
|
+
color: var(--jv-boolean);
|
|
199
|
+
font-weight: 600;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
.null {
|
|
203
|
+
color: var(--pr-muted);
|
|
204
|
+
font-style: italic;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/* "show all" / "show less" on a truncated value. A link rather than a chip: it
|
|
208
|
+
sits inside a line of text and must not become the loudest thing on it. */
|
|
209
|
+
.more {
|
|
210
|
+
box-sizing: border-box;
|
|
211
|
+
width: auto;
|
|
212
|
+
height: auto;
|
|
213
|
+
min-width: 0;
|
|
214
|
+
min-height: 0;
|
|
215
|
+
padding: 0;
|
|
216
|
+
margin: 0;
|
|
217
|
+
color: var(--pr-accent);
|
|
218
|
+
font: inherit;
|
|
219
|
+
font-size: 0.72rem;
|
|
220
|
+
text-decoration: underline;
|
|
221
|
+
text-transform: none;
|
|
222
|
+
letter-spacing: normal;
|
|
223
|
+
background: none;
|
|
224
|
+
border: 0;
|
|
225
|
+
box-shadow: none;
|
|
226
|
+
cursor: pointer;
|
|
227
|
+
appearance: none;
|
|
228
|
+
|
|
229
|
+
&:focus-visible {
|
|
230
|
+
outline: 2px solid var(--pr-accent);
|
|
231
|
+
outline-offset: 2px;
|
|
232
|
+
}
|
|
233
|
+
}
|