@lotics/ui 44.14.0 → 45.2.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.
- package/AGENTS.md +8 -1
- package/MIGRATION.md +61 -5
- package/docs/ai_patterns.md +2 -3
- package/docs/catalog.md +25 -12
- package/docs/composition.md +143 -42
- package/docs/data_entry.md +2 -3
- package/docs/reviewing.md +24 -12
- package/docs/templates.md +1 -1
- package/package.json +2 -2
- package/src/accordion.tsx +2 -2
- package/src/avatar_group.tsx +1 -1
- package/src/avatar_size.ts +2 -3
- package/src/card_select_item.tsx +7 -6
- package/src/color_tokens.ts +22 -6
- package/src/combobox.tsx +11 -1
- package/src/control_surface.ts +22 -16
- package/src/date_stamp.tsx +1 -3
- package/src/detail_row.tsx +4 -5
- package/src/dialog.tsx +13 -6
- package/src/drawer.tsx +18 -4
- package/src/filter_chip.tsx +1 -2
- package/src/finding.tsx +24 -7
- package/src/heading_altitude.ts +65 -0
- package/src/inline_text_input.tsx +4 -5
- package/src/ledger.tsx +3 -3
- package/src/markdown.web.tsx +3 -6
- package/src/matrix.tsx +1 -1
- package/src/modal.tsx +10 -1
- package/src/popover.tsx +151 -142
- package/src/pressable_row.tsx +24 -15
- package/src/reference_field.tsx +11 -9
- package/src/section_heading.tsx +57 -71
- package/src/stepper.tsx +2 -3
- package/src/summary.tsx +3 -3
- package/src/table.tsx +4 -1
- package/src/text_utils.ts +3 -4
- package/src/timeline.tsx +3 -4
package/src/popover.tsx
CHANGED
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
} from "./popover_layers";
|
|
24
24
|
import { PopoverNavContext, type PopoverNavContextValue } from "./popover_nav";
|
|
25
25
|
import { useLoticsLocale } from "./locale";
|
|
26
|
+
import { HeadingAltitudeContext } from "./heading_altitude";
|
|
26
27
|
|
|
27
28
|
export type PopoverSide = "top" | "right" | "bottom" | "left";
|
|
28
29
|
export type PopoverAlign = "start" | "center" | "end";
|
|
@@ -607,155 +608,163 @@ export function PopoverContent(props: PopoverContentProps) {
|
|
|
607
608
|
const contentZIndex = baseZIndex + nestingLevel * 2 + 1;
|
|
608
609
|
|
|
609
610
|
return (
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
611
|
+
// A popover is dialog-scale — a few hundred px with its own chrome — so a
|
|
612
|
+
// heading inside it takes the ramp's `####` rung, exactly as one inside a
|
|
613
|
+
// dialog does. The provider covers ALL THREE BANDS, not just the scroller:
|
|
614
|
+
// `PopoverHeader` is a slot the CALLER composes (unlike a Drawer's title,
|
|
615
|
+
// which is chrome the container renders), and the identity line of a peek
|
|
616
|
+
// lives there. See `heading_altitude.ts`.
|
|
617
|
+
<HeadingAltitudeContext.Provider value="panel">
|
|
618
|
+
<Portal>
|
|
619
|
+
{/* Modal scrim — ONLY for the bottom-sheet (`small`) mode, which IS modal.
|
|
620
|
+
The anchored popover is NON-MODAL: no overlay, so the rest of the page
|
|
621
|
+
stays interactive; outside-dismiss is the click listener above. */}
|
|
622
|
+
{small && (
|
|
623
|
+
<div
|
|
624
|
+
style={{
|
|
625
|
+
position: "fixed",
|
|
626
|
+
top: 0,
|
|
627
|
+
left: 0,
|
|
628
|
+
right: 0,
|
|
629
|
+
bottom: 0,
|
|
630
|
+
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
|
631
|
+
opacity: isBottomSheetShown ? 1 : 0,
|
|
632
|
+
transition: "opacity 0.3s ease",
|
|
633
|
+
zIndex: overlayZIndex,
|
|
634
|
+
pointerEvents: "auto",
|
|
635
|
+
}}
|
|
636
|
+
onClick={handleOverlayClick}
|
|
637
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
638
|
+
/>
|
|
639
|
+
)}
|
|
640
|
+
{/* Popover */}
|
|
615
641
|
<div
|
|
642
|
+
ref={popoverRef}
|
|
643
|
+
data-popover="true"
|
|
644
|
+
data-popover-level={nestingLevel}
|
|
645
|
+
data-testid={testID}
|
|
646
|
+
role="dialog"
|
|
647
|
+
aria-modal={small ? true : undefined}
|
|
648
|
+
tabIndex={small ? undefined : -1}
|
|
616
649
|
style={{
|
|
617
650
|
position: "fixed",
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
onMouseDown={(e) => e.stopPropagation()}
|
|
630
|
-
/>
|
|
631
|
-
)}
|
|
632
|
-
{/* Popover */}
|
|
633
|
-
<div
|
|
634
|
-
ref={popoverRef}
|
|
635
|
-
data-popover="true"
|
|
636
|
-
data-popover-level={nestingLevel}
|
|
637
|
-
data-testid={testID}
|
|
638
|
-
role="dialog"
|
|
639
|
-
aria-modal={small ? true : undefined}
|
|
640
|
-
tabIndex={small ? undefined : -1}
|
|
641
|
-
style={{
|
|
642
|
-
position: "fixed",
|
|
643
|
-
padding: PANEL_INSET,
|
|
644
|
-
borderTopLeftRadius: 16,
|
|
645
|
-
borderTopRightRadius: 16,
|
|
646
|
-
borderBottomLeftRadius: small ? 0 : 16,
|
|
647
|
-
borderBottomRightRadius: small ? 0 : 16,
|
|
648
|
-
backgroundColor: colors.background,
|
|
649
|
-
boxShadow: colors.shadow,
|
|
650
|
-
boxSizing: "border-box",
|
|
651
|
-
zIndex: contentZIndex,
|
|
652
|
-
transition: small ? "transform 0.3s ease" : undefined,
|
|
653
|
-
...(small
|
|
654
|
-
? {
|
|
655
|
-
bottom: 0,
|
|
656
|
-
left: 0,
|
|
657
|
-
right: 0,
|
|
658
|
-
maxHeight: "90vh",
|
|
659
|
-
display: "flex",
|
|
660
|
-
flexDirection: "column",
|
|
661
|
-
paddingBottom: 32,
|
|
662
|
-
transform: isBottomSheetShown ? "translateY(0)" : "translateY(100%)",
|
|
663
|
-
}
|
|
664
|
-
: position
|
|
651
|
+
padding: PANEL_INSET,
|
|
652
|
+
borderTopLeftRadius: 16,
|
|
653
|
+
borderTopRightRadius: 16,
|
|
654
|
+
borderBottomLeftRadius: small ? 0 : 16,
|
|
655
|
+
borderBottomRightRadius: small ? 0 : 16,
|
|
656
|
+
backgroundColor: colors.background,
|
|
657
|
+
boxShadow: colors.shadow,
|
|
658
|
+
boxSizing: "border-box",
|
|
659
|
+
zIndex: contentZIndex,
|
|
660
|
+
transition: small ? "transform 0.3s ease" : undefined,
|
|
661
|
+
...(small
|
|
665
662
|
? {
|
|
666
|
-
|
|
667
|
-
left:
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
maxHeight: "80vh",
|
|
663
|
+
bottom: 0,
|
|
664
|
+
left: 0,
|
|
665
|
+
right: 0,
|
|
666
|
+
maxHeight: "90vh",
|
|
671
667
|
display: "flex",
|
|
672
668
|
flexDirection: "column",
|
|
669
|
+
paddingBottom: 32,
|
|
670
|
+
transform: isBottomSheetShown ? "translateY(0)" : "translateY(100%)",
|
|
673
671
|
}
|
|
674
|
-
:
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
672
|
+
: position
|
|
673
|
+
? {
|
|
674
|
+
top: position.top,
|
|
675
|
+
left: position.left,
|
|
676
|
+
width: "max-content",
|
|
677
|
+
maxWidth: 800,
|
|
678
|
+
maxHeight: "80vh",
|
|
679
|
+
display: "flex",
|
|
680
|
+
flexDirection: "column",
|
|
681
|
+
}
|
|
682
|
+
: {
|
|
683
|
+
top: -9999,
|
|
684
|
+
left: -9999,
|
|
685
|
+
width: "max-content",
|
|
686
|
+
maxWidth: 800,
|
|
687
|
+
maxHeight: "80vh",
|
|
688
|
+
display: "flex",
|
|
689
|
+
flexDirection: "column",
|
|
690
|
+
}),
|
|
691
|
+
// INHERIT means inherit — the width, not just a floor under it. The
|
|
692
|
+
// flag is set only by a popover anchored to a FIELD (Select,
|
|
693
|
+
// InlineSelect, Combobox), and a field's own width is the right width
|
|
694
|
+
// for the list of values that field can hold: option text longer than
|
|
695
|
+
// the field belongs on a second line, not on a wider panel.
|
|
696
|
+
//
|
|
697
|
+
// As a floor alone this LOOKED right, because with short options
|
|
698
|
+
// max-content lands on the trigger width anyway. It broke the moment
|
|
699
|
+
// an option was a sentence: the panel grew to the `maxWidth: 800` cap
|
|
700
|
+
// and then slid sideways to stay on screen, so a 371px field opened an
|
|
701
|
+
// 800px list starting 421px to its left, outside the drawer that owns
|
|
702
|
+
// the field.
|
|
703
|
+
//
|
|
704
|
+
// Floored at MIN_CONTROL_WIDTH so a genuinely narrow trigger — a
|
|
705
|
+
// a select in a dense grid column — still opens a list
|
|
706
|
+
// wide enough to read, rather than inheriting a width nothing fits in.
|
|
707
|
+
...(inheritTriggerWidth &&
|
|
708
|
+
!small &&
|
|
709
|
+
triggerWidth > 0 && {
|
|
710
|
+
width: Math.max(triggerWidth, MIN_CONTROL_WIDTH),
|
|
711
|
+
maxWidth: Math.max(triggerWidth, MIN_CONTROL_WIDTH),
|
|
712
|
+
}),
|
|
713
|
+
// Last, so it beats the `max-content` every branch above sets.
|
|
714
|
+
...(width != null && !small ? { width, maxWidth: width } : null),
|
|
715
|
+
}}
|
|
716
|
+
onClick={(e) => e.stopPropagation()}
|
|
717
|
+
// React synthetic events bubble through portals via the REACT tree, so a
|
|
718
|
+
// keydown inside this popover — or inside a Modal/Alert opened from it,
|
|
719
|
+
// which is a React child even though it portals to document.body — would
|
|
720
|
+
// reach the TRIGGER's ancestors (e.g. a grid cell's Escape-cancels-edit
|
|
721
|
+
// onKeyDown) and let a lower layer act on a higher layer's keys, on
|
|
722
|
+
// keydown, before any keyup layering logic runs. Keyboard sibling of the
|
|
723
|
+
// click curtain above. Keyup deliberately keeps flowing: RN-web Modal
|
|
724
|
+
// closes on a document-level keyup listener, and this popover's own
|
|
725
|
+
// Escape handling is a document capture keyup — neither must be starved.
|
|
726
|
+
onKeyDown={(e) => e.stopPropagation()}
|
|
727
|
+
>
|
|
728
|
+
{small && (
|
|
729
|
+
<View
|
|
730
|
+
style={{
|
|
731
|
+
padding: 8,
|
|
732
|
+
flexDirection: "row",
|
|
733
|
+
justifyContent: "flex-end",
|
|
734
|
+
}}
|
|
735
|
+
>
|
|
736
|
+
<IconButton icon="x" tooltip={closeLabel} onPress={handleClose} />
|
|
737
|
+
</View>
|
|
738
|
+
)}
|
|
739
|
+
{header}
|
|
740
|
+
{disableBodyScroll ? (
|
|
741
|
+
<SizeBoundary style={style}>{bodyChildren}</SizeBoundary>
|
|
742
|
+
) : (
|
|
743
|
+
// FULL-BLEED HORIZONTALLY, then re-inset by the same 12. The panel
|
|
744
|
+
// pads all four sides, so a scroller sitting inside that padding
|
|
745
|
+
// clips its content 12px short of the edge and parks the scrollbar
|
|
746
|
+
// there too — content slides under an invisible margin instead of
|
|
747
|
+
// under the panel's own edge. Pulling out and padding back keeps the
|
|
748
|
+
// text on the exact column the header and footer use while giving the
|
|
749
|
+
// scroll its real edges. `PopoverFooter` already does this dance for
|
|
750
|
+
// its divider; the body needed it for the same reason.
|
|
751
|
+
//
|
|
752
|
+
// Horizontal only: the vertical padding is the gap to the header and
|
|
753
|
+
// footer, which is a gap the reader wants.
|
|
754
|
+
<ScrollView
|
|
755
|
+
style={[SCROLL_BODY, style]}
|
|
756
|
+
contentContainerStyle={[SCROLL_BODY_CONTENT, contentContainerStyle]}
|
|
757
|
+
>
|
|
758
|
+
{/* A popover is a box of its own — a few hundred px — so its contents
|
|
759
|
+
size to the panel rather than to whatever region it was opened
|
|
760
|
+
from. Both body paths get it; one of them is not a boundary. */}
|
|
761
|
+
<SizeBoundary>{bodyChildren}</SizeBoundary>
|
|
762
|
+
</ScrollView>
|
|
763
|
+
)}
|
|
764
|
+
{footer}
|
|
765
|
+
</div>
|
|
766
|
+
</Portal>
|
|
767
|
+
</HeadingAltitudeContext.Provider>
|
|
759
768
|
);
|
|
760
769
|
}
|
|
761
770
|
|
package/src/pressable_row.tsx
CHANGED
|
@@ -8,9 +8,9 @@ export interface PressableRowProps {
|
|
|
8
8
|
onPress: () => void;
|
|
9
9
|
/** The open/selected record — paints the persistent highlight. */
|
|
10
10
|
selected?: boolean;
|
|
11
|
-
/** Part of a multi-select set — paints
|
|
12
|
-
*
|
|
13
|
-
*
|
|
11
|
+
/** Part of a multi-select set — paints the same resting ground `selected` does.
|
|
12
|
+
* What tells a ticked row from an open one is the ticked checkbox, which is
|
|
13
|
+
* unmissable; the press wash still overrides both. */
|
|
14
14
|
marked?: boolean;
|
|
15
15
|
/**
|
|
16
16
|
* - "register" (THE record-list default): a rounded row whose hover/open/`marked`
|
|
@@ -89,18 +89,27 @@ export function PressableRow(props: PressableRowProps) {
|
|
|
89
89
|
// FOUR states, and they must not collide. Selection and hover painted
|
|
90
90
|
// the SAME wash, which made "the row whose record is open" and "the row
|
|
91
91
|
// the pointer happens to be over" indistinguishable — the register's
|
|
92
|
-
// one piece of persistent state, erased by a transient one.
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
// that
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
|
|
92
|
+
// one piece of persistent state, erased by a transient one.
|
|
93
|
+
//
|
|
94
|
+
// ONE NEUTRAL STEP is what separates them: zinc-50 hovered, zinc-100
|
|
95
|
+
// selected, zinc-200 pressed. The fix was briefly to give selection a
|
|
96
|
+
// blue ground instead, which read as a brand nobody had chosen on the
|
|
97
|
+
// busiest surface in the product — and contradicted `accent`'s own rule
|
|
98
|
+
// that it does not paint interaction chrome, which is exactly what
|
|
99
|
+
// these four states are.
|
|
100
|
+
//
|
|
101
|
+
// THE LITERAL, not `accent_wash`. Reading a selection through the brand
|
|
102
|
+
// token is how the blue got here in the first place: a themed app would
|
|
103
|
+
// tint the row, so "selection is neutral" would be true of the kit and
|
|
104
|
+
// false of every app that set an accent. The token still exists and
|
|
105
|
+
// still means the brand's tint — it is spent on ATTENTION now (a drop
|
|
106
|
+
// target lighting up), never on which record is open.
|
|
107
|
+
//
|
|
108
|
+
// `marked` shares the ground rather than keeping its own blue-50: what
|
|
109
|
+
// separates a ticked row from an open one is the ticked checkbox, which
|
|
110
|
+
// is unmissable, and two grounds for two orthogonal states could never
|
|
111
|
+
// render anyway on a row that is both.
|
|
112
|
+
backgroundColor: pressed ? colors.zinc[200] : selected || marked ? colors.zinc[100] : hovered ? colors.zinc[50] : undefined,
|
|
104
113
|
},
|
|
105
114
|
style,
|
|
106
115
|
]}
|
package/src/reference_field.tsx
CHANGED
|
@@ -7,7 +7,7 @@ import { formatDate } from "./format_date";
|
|
|
7
7
|
import { InlineEditView } from "./inline_edit";
|
|
8
8
|
import { InlineStatic } from "./inline_static";
|
|
9
9
|
import { Popover, PopoverContent, PopoverFooter, PopoverHeader } from "./popover";
|
|
10
|
-
import {
|
|
10
|
+
import { SectionHeadingTitle } from "./section_heading";
|
|
11
11
|
import { Text } from "./text";
|
|
12
12
|
import { TextInputField } from "./text_input_field";
|
|
13
13
|
import { TextLink } from "./text_link";
|
|
@@ -256,19 +256,21 @@ export function ReferenceField(props: ReferenceFieldProps) {
|
|
|
256
256
|
</View>
|
|
257
257
|
}
|
|
258
258
|
/>
|
|
259
|
-
{/* THE PEEK'S FORMAT — the kit's own grammar, not a bespoke one.
|
|
260
|
-
is
|
|
261
|
-
|
|
262
|
-
|
|
259
|
+
{/* THE PEEK'S FORMAT — the kit's own grammar, not a bespoke one. The
|
|
260
|
+
identity is a plain `SectionHeadingTitle` with the code as its
|
|
261
|
+
description rather than a hand-picked font weight — a popover is
|
|
262
|
+
dialog-scale, so the ramp puts it on the `####` rung by itself, and
|
|
263
|
+
this call site no longer names the surface it is standing
|
|
264
|
+
on; the facts are `DetailRow`s, which
|
|
263
265
|
is what label-beside-value IS everywhere else on this page; and the
|
|
264
266
|
verbs sit in the popover's own pinned footer rather than floating after
|
|
265
267
|
the last fact.
|
|
266
268
|
|
|
267
269
|
WIDTH COMES FROM THE FIELD (`inheritTriggerWidth`), not a constant. A
|
|
268
270
|
fixed 320 was "every peek in an app is the same object", and the object
|
|
269
|
-
it was actually the same as was nothing on screen:
|
|
270
|
-
|
|
271
|
-
|
|
271
|
+
it was actually the same as was nothing on screen: a wide field opened a
|
|
272
|
+
narrow panel left-aligned under it, stopping short of the edge the
|
|
273
|
+
reader's eye had just travelled to. A
|
|
272
274
|
popover narrower than the control that opened it reads as belonging to
|
|
273
275
|
something else. `InlineSelect` — the same shape, an inline field opening
|
|
274
276
|
a `triggerRef` popover — already inherits its field's width, so the two
|
|
@@ -294,7 +296,7 @@ export function ReferenceField(props: ReferenceFieldProps) {
|
|
|
294
296
|
is never what scrolls out of view. */}
|
|
295
297
|
<PopoverContent style={{ maxHeight: 420 }}>
|
|
296
298
|
<PopoverHeader>
|
|
297
|
-
<
|
|
299
|
+
<SectionHeadingTitle description={code}>{name}</SectionHeadingTitle>
|
|
298
300
|
</PopoverHeader>
|
|
299
301
|
<View style={{ gap: 12 }}>
|
|
300
302
|
{/* ONE geometry for both modes — the table's own 40px band, which is
|
package/src/section_heading.tsx
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { View, type StyleProp, type ViewStyle } from "react-native";
|
|
2
|
-
import { Text, type HeadingLevel } from "./text";
|
|
2
|
+
import { Text, type HeadingLevel, type TextSize } from "./text";
|
|
3
3
|
import { Icon, type IconName } from "./icon";
|
|
4
4
|
import { InfoPopover } from "./info_popover";
|
|
5
5
|
import { useLoticsLocale } from "./locale";
|
|
6
|
+
import { useHeadingAltitude, type HeadingAltitude } from "./heading_altitude";
|
|
6
7
|
|
|
7
8
|
// The card-less section header — the bare-canvas sibling of `CardHeader`, built
|
|
8
9
|
// the same compound way. A title (+ optional leading icon / description) on one
|
|
@@ -117,9 +118,15 @@ export interface SubsectionHeadingTitleProps {
|
|
|
117
118
|
info?: string;
|
|
118
119
|
}
|
|
119
120
|
|
|
120
|
-
/** The subsection title — ALWAYS `###` (lg semibold), the fixed step under
|
|
121
|
-
* xl section title and above the md/sm body. Grows to push siblings (a
|
|
122
|
-
* `SectionHeadingMeta`, a `Badge`, an action) to the right edge.
|
|
121
|
+
/** The subsection title — ALWAYS `###` (lg semibold), the fixed step under a
|
|
122
|
+
* PAGE's xl section title and above the md/sm body. Grows to push siblings (a
|
|
123
|
+
* `SectionHeadingMeta`, a `Badge`, an action) to the right edge.
|
|
124
|
+
*
|
|
125
|
+
* It has one altitude, not two: inside a PANEL the section rung is already
|
|
126
|
+
* the ramp's floor (`####` md), so a subsection there would out-rank its own
|
|
127
|
+
* section. A named group inside a panel's section is `Text size="sm"
|
|
128
|
+
* weight="semibold"` — a panel is one surface, and a second heading level in it
|
|
129
|
+
* is a document the reader is not reading. */
|
|
123
130
|
export function SubsectionHeadingTitle(props: SubsectionHeadingTitleProps) {
|
|
124
131
|
const { children, description, level = 3, info } = props;
|
|
125
132
|
const words = useLoticsLocale();
|
|
@@ -144,64 +151,6 @@ export function SubsectionHeadingTitle(props: SubsectionHeadingTitleProps) {
|
|
|
144
151
|
);
|
|
145
152
|
}
|
|
146
153
|
|
|
147
|
-
export interface DialogSectionHeadingTitleProps {
|
|
148
|
-
children: React.ReactNode;
|
|
149
|
-
/** Optional muted line under the title, as on `SectionHeadingTitle`. */
|
|
150
|
-
description?: string;
|
|
151
|
-
/** Leading glyph, as on `SectionHeadingTitle`. */
|
|
152
|
-
icon?: IconName;
|
|
153
|
-
/** Heading rank. Defaults to 4 — one level under `SubsectionHeadingTitle`'s 3. */
|
|
154
|
-
level?: HeadingLevel;
|
|
155
|
-
/** An ⓘ popover after the title, as on the two rungs above. */
|
|
156
|
-
info?: string;
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
/**
|
|
160
|
-
* The section title for a surface hosted in a DIALOG — ALWAYS `####` (md semibold), the fourth
|
|
161
|
-
* rung of the same fixed ladder.
|
|
162
|
-
*
|
|
163
|
-
* It exists because `DialogHeaderTitle` is `lg`, which is the rung `SubsectionHeadingTitle`
|
|
164
|
-
* occupies: a dialog holding a multi-section surface has its own title tying with, or being
|
|
165
|
-
* outranked by, its content. The ladder stays ABSOLUTE — this is one more fixed size, never a
|
|
166
|
-
* knob on an existing rung. Do not reach for a `size`/`compact` prop on the titles above; the same
|
|
167
|
-
* rank rendering at two sizes is exactly what the fixed ramp prevents.
|
|
168
|
-
*
|
|
169
|
-
* Compose it with the SUBSECTION layout — `Subsection` › `SubsectionHeading` › this, inside a
|
|
170
|
-
* `SubsectionStack`. Only the type size steps down; the 12px body gap and the 32px between-group
|
|
171
|
-
* beat are already right for a dialog, so duplicating them under a `Dialog*` name would add three
|
|
172
|
-
* exports that differ from their siblings in nothing.
|
|
173
|
-
*
|
|
174
|
-
* <SubsectionStack divided>
|
|
175
|
-
* <Subsection>
|
|
176
|
-
* <SubsectionHeading>
|
|
177
|
-
* <DialogSectionHeadingTitle info="…">Usage</DialogSectionHeadingTitle>
|
|
178
|
-
* <SectionHeadingMeta>Aug 2 – Sep 2</SectionHeadingMeta>
|
|
179
|
-
* </SubsectionHeading>
|
|
180
|
-
* {body}
|
|
181
|
-
* </Subsection>
|
|
182
|
-
* </SubsectionStack>
|
|
183
|
-
*/
|
|
184
|
-
export function DialogSectionHeadingTitle(props: DialogSectionHeadingTitleProps) {
|
|
185
|
-
const { children, description, icon, level = 4, info } = props;
|
|
186
|
-
const words = useLoticsLocale();
|
|
187
|
-
return (
|
|
188
|
-
<View style={{ flex: 1, minWidth: HEADING_TITLE_MIN_WIDTH, gap: 2 }}>
|
|
189
|
-
<View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
|
|
190
|
-
{icon ? <Icon name={icon} size={16} /> : null}
|
|
191
|
-
<Text level={level} size="md" weight="semibold">
|
|
192
|
-
{children}
|
|
193
|
-
</Text>
|
|
194
|
-
{info ? <InfoPopover text={info} accessibilityLabel={words.sectionHeading.info} /> : null}
|
|
195
|
-
</View>
|
|
196
|
-
{description ? (
|
|
197
|
-
<Text color="zinc-500" size="sm">
|
|
198
|
-
{description}
|
|
199
|
-
</Text>
|
|
200
|
-
) : null}
|
|
201
|
-
</View>
|
|
202
|
-
);
|
|
203
|
-
}
|
|
204
|
-
|
|
205
154
|
export interface SectionHeadingProps {
|
|
206
155
|
children: React.ReactNode;
|
|
207
156
|
style?: StyleProp<ViewStyle>;
|
|
@@ -219,7 +168,15 @@ export interface SectionHeadingTitleProps {
|
|
|
219
168
|
children: React.ReactNode;
|
|
220
169
|
description?: string;
|
|
221
170
|
icon?: IconName;
|
|
222
|
-
/**
|
|
171
|
+
/**
|
|
172
|
+
* Heading rank, when the surface's own outline needs one the altitude does not
|
|
173
|
+
* give it. Leave it unset: the default is the rank of whichever rung the
|
|
174
|
+
* altitude selected (`2` on a page, `4` in a panel), so an outline is
|
|
175
|
+
* correct without anyone thinking about it.
|
|
176
|
+
*
|
|
177
|
+
* It does NOT move the type size — the ramp is absolute, and a rank rendering
|
|
178
|
+
* at two sizes is exactly what a fixed ladder exists to prevent.
|
|
179
|
+
*/
|
|
223
180
|
level?: HeadingLevel;
|
|
224
181
|
/** Title weight. Defaults to `semibold` — a section title must separate from
|
|
225
182
|
* the body rows at a glance (medium blends in; it remains as an opt-down for
|
|
@@ -230,21 +187,50 @@ export interface SectionHeadingTitleProps {
|
|
|
230
187
|
info?: string;
|
|
231
188
|
}
|
|
232
189
|
|
|
233
|
-
/**
|
|
234
|
-
*
|
|
235
|
-
*
|
|
236
|
-
*
|
|
237
|
-
*
|
|
190
|
+
/**
|
|
191
|
+
* THE SECTION RUNG, per altitude — the two fixed points of one ladder, written
|
|
192
|
+
* down once.
|
|
193
|
+
*
|
|
194
|
+
* On a PAGE the section title is `##` (xl), under the `#` xxl identity band.
|
|
195
|
+
* Inside a PANEL it is `####` (md), under the container's `lg` chrome title —
|
|
196
|
+
* the ladder does not gain a knob, it gains a second fixed point, chosen by
|
|
197
|
+
* where the heading IS rather than by what the caller remembered to pass.
|
|
198
|
+
*
|
|
199
|
+
* The glyph steps with the type: a 20px icon beside 16px text is a mark that
|
|
200
|
+
* outweighs the words it introduces.
|
|
201
|
+
*/
|
|
202
|
+
const SECTION_RUNG = {
|
|
203
|
+
page: { size: "xl", level: 2, icon: 20 },
|
|
204
|
+
panel: { size: "md", level: 4, icon: 16 },
|
|
205
|
+
} as const satisfies Record<HeadingAltitude, { size: TextSize; level: HeadingLevel; icon: number }>;
|
|
206
|
+
|
|
207
|
+
/** The title block — the heading ramp's SECTION rung, on the fixed markdown
|
|
208
|
+
* ladder `#` xxl (`RecordSummary`/`PageHeader`) → `##` xl (section) → `###` lg
|
|
209
|
+
* (`SubsectionHeadingTitle`) over the sm body. No size knob: which rung it
|
|
210
|
+
* renders is decided by the SURFACE, not the call site — `##` xl on a page,
|
|
211
|
+
* `####` md inside a panel's content region, where the container's own title
|
|
212
|
+
* is already `lg`. A caller never says which; the container publishes it (see
|
|
213
|
+
* `heading_altitude.ts`), so the same heading is correct on a page and in a
|
|
214
|
+
* drawer without being written twice.
|
|
215
|
+
*
|
|
216
|
+
* Inside a panel this rung is the ramp's FLOOR — a pane is one surface,
|
|
217
|
+
* not a document. A named group inside a section there is `Text size="sm"
|
|
218
|
+
* weight="semibold"`, not a `SubsectionHeadingTitle`, which is `lg` at every
|
|
219
|
+
* altitude and would out-rank the section holding it.
|
|
220
|
+
*
|
|
221
|
+
* Optional muted `description` line; grows to push any sibling actions to the
|
|
222
|
+
* right edge. */
|
|
238
223
|
export function SectionHeadingTitle(props: SectionHeadingTitleProps) {
|
|
239
|
-
const { children, description, icon, level
|
|
224
|
+
const { children, description, icon, level, weight = "semibold", info } = props;
|
|
240
225
|
// Hoisted — a hook inside the `info ?` ternary breaks hook ordering the
|
|
241
226
|
// render `info` appears or disappears.
|
|
242
227
|
const words = useLoticsLocale();
|
|
228
|
+
const rung = SECTION_RUNG[useHeadingAltitude()];
|
|
243
229
|
return (
|
|
244
230
|
<View style={{ flex: 1, minWidth: HEADING_TITLE_MIN_WIDTH, gap: 2 }}>
|
|
245
231
|
<View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
|
|
246
|
-
{icon ? <Icon name={icon} size={
|
|
247
|
-
<Text level={level} weight={weight} size=
|
|
232
|
+
{icon ? <Icon name={icon} size={rung.icon} /> : null}
|
|
233
|
+
<Text level={level ?? rung.level} weight={weight} size={rung.size}>
|
|
248
234
|
{children}
|
|
249
235
|
</Text>
|
|
250
236
|
{info ? <InfoPopover text={info} accessibilityLabel={words.sectionHeading.info} /> : null}
|
package/src/stepper.tsx
CHANGED
|
@@ -288,9 +288,8 @@ export function Step(props: StepProps) {
|
|
|
288
288
|
on the first content row, so there is a `markerTopOffset` band of
|
|
289
289
|
empty column above it. A single below-the-marker segment cannot
|
|
290
290
|
reach into the NEXT step's band, so the line broke at every joint
|
|
291
|
-
—
|
|
292
|
-
next one's offset above it
|
|
293
|
-
and 3px, at every stage, which reads as dashes rather than a spine.
|
|
291
|
+
— a gap under each marker (this segment's old top margin) plus the
|
|
292
|
+
next one's offset above it, which reads as dashes rather than a spine.
|
|
294
293
|
The upper segment fills that band, so the line is continuous from
|
|
295
294
|
the first marker to the last. It also replaces the column's old
|
|
296
295
|
`paddingTop`, which is why the marker lands in the same place. */}
|
package/src/summary.tsx
CHANGED
|
@@ -231,9 +231,9 @@ const styles = StyleSheet.create({
|
|
|
231
231
|
// 12, and it is half of what a page must put BETWEEN this band and its
|
|
232
232
|
// neighbours. The band's parts are one group — a figure, the shape of what it
|
|
233
233
|
// counts, and that shape's key — so they have to sit closer to each other than
|
|
234
|
-
// the band sits to the register below it.
|
|
235
|
-
//
|
|
236
|
-
// grouped, which is what "no rhythm"
|
|
234
|
+
// the band sits to the register below it. At near-parity with the page gap
|
|
235
|
+
// every block on the screen is an equal peer, nothing
|
|
236
|
+
// grouped, which is what "no rhythm" means.
|
|
237
237
|
root: { gap: 12 },
|
|
238
238
|
header: {
|
|
239
239
|
flexDirection: "row",
|
package/src/table.tsx
CHANGED
|
@@ -612,7 +612,10 @@ const styles = StyleSheet.create({
|
|
|
612
612
|
// A hairline under the column header anchors the columns; the rows below it are
|
|
613
613
|
// Divider-separated.
|
|
614
614
|
headerBandFilled: {
|
|
615
|
-
|
|
615
|
+
// The literal neutral, not `accent_wash`: this band is a lid on the columns,
|
|
616
|
+
// never a "you are here", and reading it through the brand token made a
|
|
617
|
+
// themed app tint a piece of pure furniture.
|
|
618
|
+
backgroundColor: colors.zinc[100],
|
|
616
619
|
// The band bleeds to the row wash's width so its edges line up with a hovered
|
|
617
620
|
// row beneath it; without this the lid is narrower than the rows it caps.
|
|
618
621
|
marginHorizontal: -ROW_WASH_BLEED,
|