@ptbdynamic/plinth 0.1.1 → 0.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.
Files changed (39) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/packages/css/dist/menu.css +245 -0
  4. package/packages/css/dist/page-header.css +124 -0
  5. package/packages/css/dist/plinth.css +833 -160
  6. package/packages/css/dist/section-menu.css +6 -5
  7. package/packages/css/dist/text-field.css +230 -0
  8. package/packages/css/dist/textarea.css +169 -0
  9. package/packages/css/dist/tokens.css +9 -1
  10. package/packages/react/dist/PlinthMenu.d.ts +12 -0
  11. package/packages/react/dist/PlinthMenuGroup.d.ts +9 -0
  12. package/packages/react/dist/PlinthMenuItem.d.ts +26 -0
  13. package/packages/react/dist/PlinthMenuSeparator.d.ts +4 -0
  14. package/packages/react/dist/PlinthPageHeader.d.ts +17 -0
  15. package/packages/react/dist/PlinthTextField.d.ts +24 -0
  16. package/packages/react/dist/PlinthTextarea.d.ts +24 -0
  17. package/packages/react/dist/index.d.ts +10 -1
  18. package/packages/react/dist/index.js +565 -366
  19. package/packages/react/dist/menu.d.ts +7 -0
  20. package/packages/react/dist/page-header.d.ts +5 -0
  21. package/packages/react/dist/text-field.d.ts +10 -0
  22. package/packages/tokens/dist/index.d.ts +13 -1
  23. package/packages/tokens/dist/index.js +131 -6
  24. package/packages/tokens/dist/tailwind-theme.css +2 -2
  25. package/packages/tokens/dist/tokens.css +9 -1
  26. package/packages/tokens/dist/tokens.json +116 -3
  27. package/packages/vue/dist/PlinthMenu.vue.d.ts +24 -0
  28. package/packages/vue/dist/{PlinthHeader.vue.d.ts → PlinthMenuGroup.vue.d.ts} +5 -7
  29. package/packages/vue/dist/PlinthMenuItem.vue.d.ts +34 -0
  30. package/packages/vue/dist/PlinthMenuSeparator.vue.d.ts +3 -0
  31. package/packages/vue/dist/PlinthPageHeader.vue.d.ts +27 -0
  32. package/packages/vue/dist/PlinthTextField.vue.d.ts +51 -0
  33. package/packages/vue/dist/PlinthTextarea.vue.d.ts +42 -0
  34. package/packages/vue/dist/index.d.ts +11 -2
  35. package/packages/vue/dist/index.js +661 -368
  36. package/packages/vue/dist/menu.d.ts +8 -0
  37. package/packages/vue/dist/page-header.d.ts +5 -0
  38. package/packages/vue/dist/text-field.d.ts +10 -0
  39. package/packages/react/dist/PlinthHeader.d.ts +0 -12
@@ -87,6 +87,8 @@
87
87
  --plinth-ref-size-44: 2.75rem;
88
88
  --plinth-ref-size-56: 3.5rem;
89
89
  --plinth-ref-size-160: 10rem;
90
+ --plinth-ref-size-192: 12rem;
91
+ --plinth-ref-size-320: 20rem;
90
92
  --plinth-ref-size-400: 25rem;
91
93
  --plinth-ref-size-480: 30rem;
92
94
  --plinth-ref-radius-0: 0;
@@ -199,6 +201,8 @@
199
201
  --plinth-size-dialog: var(--plinth-ref-size-480);
200
202
  --plinth-size-panel: var(--plinth-ref-size-400);
201
203
  --plinth-size-peek: var(--plinth-ref-size-160);
204
+ --plinth-size-menu-min: var(--plinth-ref-size-192);
205
+ --plinth-size-menu-max: var(--plinth-ref-size-320);
202
206
  --plinth-layer-base: 0;
203
207
  --plinth-layer-sticky: 100;
204
208
  --plinth-layer-overlay: 200;
@@ -206,7 +210,8 @@
206
210
  --plinth-layer-toast: 400;
207
211
  --plinth-breakpoint-md: 768px;
208
212
  --plinth-breakpoint-lg: 1280px;
209
- --plinth-header-height: var(--plinth-space-14);
213
+ --plinth-page-header-height: var(--plinth-space-14);
214
+ --plinth-page-header-offset: var(--plinth-space-0);
210
215
  --plinth-data-table-row-height: var(--plinth-size-row);
211
216
  --plinth-list-row-height: var(--plinth-size-row);
212
217
  --plinth-pill-height: var(--plinth-control-md);
@@ -215,6 +220,9 @@
215
220
  --plinth-drawer-peek-height: var(--plinth-size-peek);
216
221
  --plinth-section-menu-height: var(--plinth-space-14);
217
222
  --plinth-section-menu-item-height: var(--plinth-control-lg);
223
+ --plinth-menu-min-width: var(--plinth-size-menu-min);
224
+ --plinth-menu-max-width: var(--plinth-size-menu-max);
225
+ --plinth-text-field-trailing-size: var(--plinth-control-md);
218
226
  }
219
227
 
220
228
  :root,
@@ -2241,118 +2249,6 @@
2241
2249
  }
2242
2250
  }
2243
2251
 
2244
- /* header.css */
2245
- /*
2246
- * Header: the top bar of the phone shell, with a leading action, the page's title and trailing
2247
- * actions. It sticks to the top of the page as the content scrolls under it.
2248
- *
2249
- * <header class="plinth-header">
2250
- * <div class="plinth-header-leading">
2251
- * <a class="plinth-button plinth-icon-button" href="/inbox" data-variant="ghost"
2252
- * data-size="md" aria-label="Back">
2253
- * <span class="plinth-icon" data-size="md" data-mirror aria-hidden="true"><svg>…</svg></span>
2254
- * </a>
2255
- * </div>
2256
- * <h1 class="plinth-header-title">Inbox</h1>
2257
- * <div class="plinth-header-actions">
2258
- * <button type="button" class="plinth-button plinth-icon-button" data-variant="ghost"
2259
- * data-size="md" aria-label="Search">…</button>
2260
- * </div>
2261
- * </header>
2262
- *
2263
- * The root is a <header>: at the top of the page (not inside <main>, <article> or <section>) it is
2264
- * the page's banner landmark, so a page has one. The title is an <h1>, the page's heading, and is
2265
- * required. It stays on one line and ends in an ellipsis when it doesn't fit.
2266
- *
2267
- * The leading part holds one action, usually an icon button: back (a link, with data-mirror on
2268
- * its icon so it points the other way in right-to-left text) or a menu button that opens a sheet
2269
- * (commandfor and command="show-modal"). The actions part holds the trailing actions, at most two
2270
- * or three icon buttons; put the rest in a menu. Both are optional, and printed only when they
2271
- * have content. Every icon button needs its own name (label). The header needs button.css and
2272
- * icon.css for them.
2273
- *
2274
- * Its height is the header.height component token (56px), plus env(safe-area-inset-top), which
2275
- * pads it below the status bar and the notch on a phone that draws under them (viewport-fit=cover);
2276
- * the inline padding clears the notch on a phone held sideways. It is sticky on the
2277
- * layer.sticky z-index, so it sits above the page and below sheets and menus (the top layer).
2278
- * Content scrolls under it, so the app sets scroll-padding-block-start on its scrolling element
2279
- * (calc(var(--plinth-header-height) + env(safe-area-inset-top))) to keep a focused element or an
2280
- * anchor's target from landing under the header.
2281
- *
2282
- * The actions sit var(--plinth-space-2) apart, so on a touch screen the 40px hit areas of 32px
2283
- * icon buttons don't overlap.
2284
- *
2285
- * The app owns the state: the header has no variants or states of its own. What the actions do is
2286
- * the app's (a link, a commandfor, wire:click, @click on the icon button).
2287
- */
2288
-
2289
- @layer plinth {
2290
- .plinth-header {
2291
- position: sticky;
2292
- inset-block-start: 0;
2293
- z-index: var(--plinth-layer-sticky);
2294
- box-sizing: border-box;
2295
- display: flex;
2296
- gap: var(--plinth-space-2);
2297
- align-items: center;
2298
- min-block-size: calc(var(--plinth-header-height) + env(safe-area-inset-top));
2299
- padding-block-start: env(safe-area-inset-top);
2300
- padding-inline: max(var(--plinth-space-2), env(safe-area-inset-left))
2301
- max(var(--plinth-space-2), env(safe-area-inset-right));
2302
- font-family: var(--plinth-font-family-body);
2303
- color: var(--plinth-color-text);
2304
- background-color: var(--plinth-color-surface);
2305
- border-block-end: var(--plinth-border-width-thin) solid var(--plinth-color-divider);
2306
- }
2307
-
2308
- .plinth-header:dir(rtl) {
2309
- padding-inline: max(var(--plinth-space-2), env(safe-area-inset-right))
2310
- max(var(--plinth-space-2), env(safe-area-inset-left));
2311
- }
2312
-
2313
- .plinth-header-leading,
2314
- .plinth-header-actions {
2315
- display: flex;
2316
- flex: none;
2317
- gap: var(--plinth-space-2);
2318
- align-items: center;
2319
- }
2320
-
2321
- .plinth-header-title {
2322
- flex: 1 1 auto;
2323
- min-inline-size: 0;
2324
- margin: 0;
2325
- overflow: hidden;
2326
- text-overflow: ellipsis;
2327
- font-size: var(--plinth-font-size-lg);
2328
- font-weight: var(--plinth-font-weight-semibold);
2329
- line-height: var(--plinth-font-leading-tight);
2330
- letter-spacing: var(--plinth-font-tracking-tight);
2331
- white-space: nowrap;
2332
- }
2333
-
2334
- /* Without a leading action or trailing actions, the title keeps the text's distance from the edge. */
2335
- .plinth-header-title:first-child {
2336
- margin-inline-start: var(--plinth-space-2);
2337
- }
2338
-
2339
- .plinth-header-title:last-child {
2340
- margin-inline-end: var(--plinth-space-2);
2341
- }
2342
-
2343
- @media (min-width: 768px) {
2344
- .plinth-header {
2345
- padding-inline: max(var(--plinth-space-4), env(safe-area-inset-left))
2346
- max(var(--plinth-space-4), env(safe-area-inset-right));
2347
- }
2348
-
2349
- .plinth-header:dir(rtl) {
2350
- padding-inline: max(var(--plinth-space-4), env(safe-area-inset-right))
2351
- max(var(--plinth-space-4), env(safe-area-inset-left));
2352
- }
2353
- }
2354
- }
2355
-
2356
2252
  /* icon.css */
2357
2253
  /*
2358
2254
  * Icon: a project's own SVG, sized by Plinth.
@@ -2736,6 +2632,253 @@
2736
2632
  }
2737
2633
  }
2738
2634
 
2635
+ /* menu.css */
2636
+ /*
2637
+ * Menu: a short list of actions that opens from a button, in a popover anchored to it.
2638
+ *
2639
+ * <button type="button" class="plinth-button plinth-icon-button" popovertarget="ticket-actions"
2640
+ * aria-label="More actions">…</button>
2641
+ * <div class="plinth-menu" id="ticket-actions" popover="auto">
2642
+ * <button type="button" class="plinth-menu-item" commandfor="ticket-actions"
2643
+ * command="hide-popover">
2644
+ * <span class="plinth-menu-icon" aria-hidden="true"><svg>…</svg></span>
2645
+ * <span class="plinth-menu-label">Assign to me</span>
2646
+ * <kbd class="plinth-menu-shortcut" aria-hidden="true">A</kbd>
2647
+ * </button>
2648
+ * <a class="plinth-menu-item" href="/tickets/42/print">
2649
+ * <span class="plinth-menu-label">Print</span>
2650
+ * </a>
2651
+ * <hr class="plinth-menu-separator">
2652
+ * <div class="plinth-menu-group" role="group" aria-label="Move to">
2653
+ * <div class="plinth-menu-heading" aria-hidden="true">Move to</div>
2654
+ * <button type="button" class="plinth-menu-item" commandfor="ticket-actions"
2655
+ * command="hide-popover"><span class="plinth-menu-label">Archive</span></button>
2656
+ * </div>
2657
+ * <hr class="plinth-menu-separator">
2658
+ * <button type="button" class="plinth-menu-item" commandfor="ticket-actions"
2659
+ * command="hide-popover" data-tone="danger">
2660
+ * <span class="plinth-menu-label">Delete</span>
2661
+ * </button>
2662
+ * </div>
2663
+ *
2664
+ * The root is a <div popover="auto"> with an id (required in Blade, Vue and React), which the
2665
+ * app's button names in popovertarget (or commandfor with command="toggle-popover"). The browser
2666
+ * opens and closes it (ADR 2): the top layer, a click outside or Escape closes it, focus goes back
2667
+ * to the button, and the browser tells assistive technology whether the button's menu is
2668
+ * expanded. The button is the app's own (an icon button or a button); it is not part of the menu.
2669
+ *
2670
+ * It is not role="menu". The ARIA menu pattern needs arrow keys and a roving focus, which would be
2671
+ * script in every layer; this is a list of buttons and links, reached with Tab, as ADR 2 decides
2672
+ * for menus of actions. So the button has no aria-haspopup, and the items are plain buttons and
2673
+ * links that read as such.
2674
+ *
2675
+ * Parts, each its own component in Blade, Vue and React:
2676
+ * - item: a <button type="button">, or an <a href> when it navigates. A button item closes its
2677
+ * menu when pressed: commandfor="<the menu's id>" command="hide-popover", which the layers take
2678
+ * from the menu around the item. A link closes the menu by leaving the page; an app that routes
2679
+ * without loading a page closes it with hidePopover(). The icon is the project's SVG, decorative;
2680
+ * the label is the item's name. The shortcut is a hint for the eye, aria-hidden; the app that
2681
+ * has the shortcut adds aria-keyshortcuts to the item.
2682
+ * - separator: an <hr> between items.
2683
+ * - group: a <div role="group"> named by its heading (aria-label), with the heading shown above
2684
+ * its items and hidden from assistive technology, which reads the group's name instead.
2685
+ *
2686
+ * Variants and states:
2687
+ * - data-tone="danger" on an item that deletes or removes something: the danger text colour and a
2688
+ * danger tint on hover. The label still says what it does; the colour is not the only signal.
2689
+ * - disabled: `disabled` on a <button>; on a link, no href, role="link" and aria-disabled="true".
2690
+ * - hover only where the device can hover; pressed takes the sunken surface; the focus ring on
2691
+ * :focus-visible, drawn inside the item (focus-ring.offset-inset).
2692
+ * - open is the native one (:popover-open), never a class.
2693
+ *
2694
+ * Placement: below the button, starting at its start edge, through CSS anchor positioning (the
2695
+ * popover's button is its implicit anchor). It takes the side of the button with more room, above
2696
+ * or below, and is never taller than that room: a long menu scrolls. When it would leave the
2697
+ * screen sideways it flips to the button's end edge. It is the same at every width; a phone gets no bottom sheet,
2698
+ * since a menu is a few actions next to the button that opened them (a long list belongs in a
2699
+ * sheet). A browser without anchor positioning shows the menu in the middle of the screen, where
2700
+ * it still works. Script opens it with showPopover({ source: button }), so it has its anchor;
2701
+ * without a source it has none, and sits in the screen's start corner.
2702
+ *
2703
+ * Size: --plinth-menu-min-width (192px) to --plinth-menu-max-width (320px), never wider than the
2704
+ * screen less a margin, and never taller than the room it has. Items are --plinth-touch-target
2705
+ * (40px) tall; with a mouse from 768px up, --plinth-control-md (32px). A long label wraps.
2706
+ * It fades in and out on the motion tokens, which drop to 0ms under prefers-reduced-motion.
2707
+ */
2708
+
2709
+ @layer plinth {
2710
+ .plinth-menu {
2711
+ /* The browser's popover rules centre it on the screen: the fallback without anchoring. */
2712
+ box-sizing: border-box;
2713
+ min-inline-size: min(var(--plinth-menu-min-width), 100vi - 2 * var(--plinth-space-2));
2714
+ max-inline-size: min(var(--plinth-menu-max-width), 100vi - 2 * var(--plinth-space-2));
2715
+ max-block-size: calc(100dvb - 2 * var(--plinth-space-2));
2716
+ padding: var(--plinth-space-1);
2717
+ overflow: hidden auto;
2718
+ overscroll-behavior: contain;
2719
+ font-family: var(--plinth-font-family-body);
2720
+ font-size: var(--plinth-font-size-md);
2721
+ line-height: var(--plinth-font-leading-tight);
2722
+ color: var(--plinth-color-text);
2723
+ background-color: var(--plinth-color-surface-raised);
2724
+ border: var(--plinth-border-width-thin) solid var(--plinth-color-border);
2725
+ border-radius: var(--plinth-radius-container);
2726
+ box-shadow: var(--plinth-elevation-2);
2727
+ opacity: 0;
2728
+ transition:
2729
+ opacity var(--plinth-duration-fast) var(--plinth-easing-exit),
2730
+ overlay var(--plinth-duration-fast) allow-discrete,
2731
+ display var(--plinth-duration-fast) allow-discrete;
2732
+ }
2733
+
2734
+ .plinth-menu:popover-open {
2735
+ opacity: 1;
2736
+ transition-timing-function: var(--plinth-easing-enter);
2737
+ }
2738
+
2739
+ @starting-style {
2740
+ .plinth-menu:popover-open {
2741
+ opacity: 0;
2742
+ }
2743
+ }
2744
+
2745
+ @supports (position-area: block-end) {
2746
+ .plinth-menu {
2747
+ inset: auto;
2748
+ max-block-size: calc(100% - 2 * var(--plinth-space-1));
2749
+ margin-block: var(--plinth-space-1);
2750
+ margin-inline: 0;
2751
+ position-area: block-end span-inline-end;
2752
+ position-try-order: most-block-size;
2753
+ position-try-fallbacks:
2754
+ flip-block,
2755
+ flip-inline,
2756
+ flip-block flip-inline;
2757
+ }
2758
+ }
2759
+
2760
+ .plinth-menu-item {
2761
+ box-sizing: border-box;
2762
+ display: flex;
2763
+ gap: var(--plinth-space-2);
2764
+ align-items: center;
2765
+ inline-size: 100%;
2766
+ min-block-size: var(--plinth-touch-target);
2767
+ padding-block: var(--plinth-space-1);
2768
+ padding-inline: var(--plinth-space-2);
2769
+ margin: 0;
2770
+ font: inherit;
2771
+ color: var(--plinth-color-text);
2772
+ text-align: start;
2773
+ text-decoration: none;
2774
+ cursor: pointer;
2775
+ user-select: none;
2776
+ background-color: transparent;
2777
+ border: 0;
2778
+ border-radius: var(--plinth-radius-control);
2779
+ transition:
2780
+ background-color var(--plinth-duration-fast) var(--plinth-easing-standard),
2781
+ color var(--plinth-duration-fast) var(--plinth-easing-standard);
2782
+ }
2783
+
2784
+ .plinth-menu-item[data-tone='danger'] {
2785
+ color: var(--plinth-color-danger-text);
2786
+ }
2787
+
2788
+ @media (hover: hover) {
2789
+ a.plinth-menu-item[href]:hover,
2790
+ button.plinth-menu-item:enabled:hover {
2791
+ background-color: var(--plinth-color-surface-hover);
2792
+ }
2793
+
2794
+ a.plinth-menu-item[href][data-tone='danger']:hover,
2795
+ button.plinth-menu-item:enabled[data-tone='danger']:hover {
2796
+ background-color: var(--plinth-color-danger-surface);
2797
+ }
2798
+ }
2799
+
2800
+ a.plinth-menu-item[href]:active,
2801
+ button.plinth-menu-item:enabled:active {
2802
+ background-color: var(--plinth-color-surface-sunken);
2803
+ }
2804
+
2805
+ .plinth-menu-item:focus-visible {
2806
+ outline: var(--plinth-focus-ring-width) solid var(--plinth-color-focus-ring);
2807
+ outline-offset: var(--plinth-focus-ring-offset-inset);
2808
+ }
2809
+
2810
+ .plinth-menu-item:disabled,
2811
+ .plinth-menu-item[aria-disabled='true'] {
2812
+ cursor: not-allowed;
2813
+ opacity: var(--plinth-opacity-disabled);
2814
+ }
2815
+
2816
+ .plinth-menu-icon {
2817
+ display: inline-flex;
2818
+ flex: none;
2819
+ inline-size: var(--plinth-size-icon-sm);
2820
+ block-size: var(--plinth-size-icon-sm);
2821
+ color: var(--plinth-color-text-muted);
2822
+ }
2823
+
2824
+ .plinth-menu-item[data-tone='danger'] .plinth-menu-icon {
2825
+ color: inherit;
2826
+ }
2827
+
2828
+ .plinth-menu-icon > svg {
2829
+ inline-size: 100%;
2830
+ block-size: 100%;
2831
+ }
2832
+
2833
+ .plinth-menu-label {
2834
+ flex: 1 1 auto;
2835
+ min-inline-size: 0;
2836
+ overflow-wrap: anywhere;
2837
+ }
2838
+
2839
+ .plinth-menu-shortcut {
2840
+ flex: none;
2841
+ padding-inline-start: var(--plinth-space-4);
2842
+ margin-inline-start: auto;
2843
+ font-family: var(--plinth-font-family-mono);
2844
+ font-size: var(--plinth-font-size-xs);
2845
+ color: var(--plinth-color-text-subtle);
2846
+ }
2847
+
2848
+ .plinth-menu-separator {
2849
+ block-size: 0;
2850
+ margin-block: var(--plinth-space-1);
2851
+ margin-inline: calc(-1 * var(--plinth-space-1));
2852
+ border: 0;
2853
+ border-block-start: var(--plinth-border-width-thin) solid var(--plinth-color-divider);
2854
+ }
2855
+
2856
+ .plinth-menu-group {
2857
+ margin: 0;
2858
+ }
2859
+
2860
+ .plinth-menu-heading {
2861
+ padding-block: var(--plinth-space-2) var(--plinth-space-1);
2862
+ padding-inline: var(--plinth-space-2);
2863
+ font-size: var(--plinth-font-size-xs);
2864
+ font-weight: var(--plinth-font-weight-medium);
2865
+ color: var(--plinth-color-text-subtle);
2866
+ }
2867
+
2868
+ @media (min-width: 768px) and (pointer: fine) {
2869
+ .plinth-menu-item {
2870
+ min-block-size: var(--plinth-control-md);
2871
+ }
2872
+ }
2873
+
2874
+ @media (forced-colors: active) {
2875
+ .plinth-menu-item:disabled,
2876
+ .plinth-menu-item[aria-disabled='true'] {
2877
+ color: GrayText;
2878
+ }
2879
+ }
2880
+ }
2881
+
2739
2882
  /* modal.css */
2740
2883
  /*
2741
2884
  * Modal base: what the dialog (dialog.css), the sheet (sheet.css) and the drawer (drawer.css)
@@ -3105,62 +3248,188 @@
3105
3248
  }
3106
3249
  }
3107
3250
 
3108
- /* pill-row.css */
3251
+ /* page-header.css */
3109
3252
  /*
3110
- * Pill row: a row of pills for filters, segments or sub-pages.
3253
+ * Page header: the bar at the top of a page, with a leading action, the page's heading and
3254
+ * trailing actions. On a phone it is the screen's top bar; in an app shell it heads the page beside
3255
+ * the sidebar, under the app bar. It sticks to the top as the content scrolls under it.
3111
3256
  *
3112
- * <div class="plinth-pill-row" role="group" aria-label="Status">
3113
- * <button type="button" class="plinth-pill" aria-pressed="true">
3114
- * <span class="plinth-pill-label">Open</span>
3115
- * <span class="plinth-pill-count">12</span>
3116
- * </button>
3117
- * <button type="button" class="plinth-pill" aria-pressed="false">
3118
- * <span class="plinth-pill-label">Closed</span>
3119
- * </button>
3120
- * </div>
3257
+ * <header class="plinth-page-header" data-top>
3258
+ * <div class="plinth-page-header-leading">
3259
+ * <a class="plinth-button plinth-icon-button" href="/inbox" data-variant="ghost"
3260
+ * data-size="md" aria-label="Back">
3261
+ * <span class="plinth-icon" data-size="md" data-mirror aria-hidden="true"><svg>…</svg></span>
3262
+ * </a>
3263
+ * </div>
3264
+ * <h1 class="plinth-page-header-heading">Inbox</h1>
3265
+ * <div class="plinth-page-header-actions">
3266
+ * <button type="button" class="plinth-button plinth-icon-button" data-variant="ghost"
3267
+ * data-size="md" aria-label="Search">…</button>
3268
+ * </div>
3269
+ * </header>
3121
3270
  *
3122
- * The root is a <div role="group">, named by aria-label (label in Blade and Vue) or by the app's
3123
- * own aria-labelledby. A row of links that navigates is <nav class="plinth-pill-row"> instead,
3124
- * with no role (nav in Blade and Vue), and its pills are links:
3271
+ * The root is a <header>. At the top of the page (not inside <main>, <article>, <section>, <aside>
3272
+ * or <nav>) it is the page's banner landmark, so a page has one there; inside <main>, as in an app
3273
+ * shell, it is no landmark, only the page's header. The heading is required: an <h1> by default, or
3274
+ * <h2> to <h6> (level) when the page sits beside another page's <h1>, such as an open ticket beside
3275
+ * the list on a wide screen. The level doesn't change the look. The heading stays on one line and
3276
+ * ends in an ellipsis when it doesn't fit.
3125
3277
  *
3126
- * <a class="plinth-pill" href="…" aria-current="page"><span class="plinth-pill-label">…</span></a>
3278
+ * The leading part holds one action, usually an icon button: a menu button that opens a sheet
3279
+ * (commandfor and command="show-modal") on a top-level page, or a back link (with data-mirror on
3280
+ * its icon so it points the other way in right-to-left text) on a page under it. The actions part
3281
+ * holds the trailing actions: at most three icon buttons (put the rest in a menu), or a primary
3282
+ * button such as New ticket. Both are optional, and printed only when they have content. Every
3283
+ * icon button needs its own name (label). The page header needs button.css and icon.css for them.
3127
3284
  *
3128
- * Each pill is its own component (<x-plinth::pill>, PlinthPill), so the app's attributes and
3129
- * listeners land on the pill (ADR 2). The count is optional. plinth-pill-label has no rule of its
3130
- * own: it is a hook for a project's styles.
3285
+ * Variant (the app sets it, the page header doesn't guess):
3286
+ * - data-top: the page header is the screen's top bar, at the very top of a phone's screen. It
3287
+ * adds env(safe-area-inset-top) above its height, which pads it below the status bar and the
3288
+ * notch on a phone that draws under them (viewport-fit=cover). Leave it off under an app bar.
3131
3289
  *
3132
- * States, set by the app:
3133
- * - selected: a button pill always prints aria-pressed, "true" or "false"; a link pill prints
3134
- * aria-current="page" for the page being shown. Selected takes the selection tint
3135
- * (accent-subtle, accent-text) inside a 2px accent edge (border-width.thick), where the others
3136
- * have a 1px neutral one, so colour is not its only cue. A pressed button still answers hover
3137
- * and press with a step on the tint (accent-subtle-hover, accent-subtle-active).
3138
- * - disabled: `disabled` on a <button>; on a link, no href, role="link" and aria-disabled="true".
3139
- * - hover only where the device can hover; the focus ring on :focus-visible.
3290
+ * Its height is the page-header.height component token (56px). The inline padding clears the
3291
+ * notch on a phone held sideways. It is sticky on the layer.sticky z-index, so it sits above the
3292
+ * page and below sheets and menus (the top layer), and it sticks --plinth-page-header-offset from
3293
+ * the top: 0 by default, or the app bar's height in a shell that has one. Content scrolls under
3294
+ * it, so the app sets scroll-padding-block-start on its scrolling element (the offset plus the
3295
+ * height, plus env(safe-area-inset-top) with data-top) to keep a focused element or an anchor's
3296
+ * target from landing under the page header.
3140
3297
  *
3141
- * Layout: on a phone the row scrolls sideways, with no visible scrollbar, and snaps to the pills;
3142
- * from 768px it wraps. The row's padding keeps the focus ring and the 40px touch target of the
3143
- * pills inside the scrolling area, whatever --plinth-pill-height is (32px by default). A pill is
3144
- * at least 40px wide.
3298
+ * The actions sit var(--plinth-space-2) apart, so on a touch screen the 40px hit areas of 32px
3299
+ * icon buttons don't overlap.
3300
+ *
3301
+ * The app owns the state: the page header has no states of its own. What the actions do is the
3302
+ * app's (a link, a commandfor, wire:click, @click on the icon button).
3145
3303
  */
3146
3304
 
3147
3305
  @layer plinth {
3148
- .plinth-pill-row {
3306
+ .plinth-page-header {
3307
+ position: sticky;
3308
+ inset-block-start: var(--plinth-page-header-offset);
3309
+ z-index: var(--plinth-layer-sticky);
3149
3310
  box-sizing: border-box;
3150
3311
  display: flex;
3151
3312
  gap: var(--plinth-space-2);
3152
3313
  align-items: center;
3153
- max-inline-size: 100%;
3154
- padding-block: max(
3155
- var(--plinth-space-1),
3156
- (var(--plinth-touch-target) - var(--plinth-pill-height)) / 2
3157
- );
3158
- padding-inline: var(--plinth-space-1);
3159
- margin: 0;
3160
- overflow-x: auto;
3161
- overscroll-behavior-inline: contain;
3162
- scroll-snap-type: inline proximity;
3163
- scroll-padding-inline: var(--plinth-space-1);
3314
+ min-block-size: var(--plinth-page-header-height);
3315
+ padding-inline: max(var(--plinth-space-2), env(safe-area-inset-left))
3316
+ max(var(--plinth-space-2), env(safe-area-inset-right));
3317
+ font-family: var(--plinth-font-family-body);
3318
+ color: var(--plinth-color-text);
3319
+ background-color: var(--plinth-color-surface);
3320
+ border-block-end: var(--plinth-border-width-thin) solid var(--plinth-color-divider);
3321
+ }
3322
+
3323
+ .plinth-page-header:dir(rtl) {
3324
+ padding-inline: max(var(--plinth-space-2), env(safe-area-inset-right))
3325
+ max(var(--plinth-space-2), env(safe-area-inset-left));
3326
+ }
3327
+
3328
+ /* The screen's top bar clears the status bar and the notch. */
3329
+ .plinth-page-header[data-top] {
3330
+ min-block-size: calc(var(--plinth-page-header-height) + env(safe-area-inset-top));
3331
+ padding-block-start: env(safe-area-inset-top);
3332
+ }
3333
+
3334
+ .plinth-page-header-leading,
3335
+ .plinth-page-header-actions {
3336
+ display: flex;
3337
+ flex: none;
3338
+ gap: var(--plinth-space-2);
3339
+ align-items: center;
3340
+ }
3341
+
3342
+ .plinth-page-header-heading {
3343
+ flex: 1 1 auto;
3344
+ min-inline-size: 0;
3345
+ margin: 0;
3346
+ overflow: hidden;
3347
+ text-overflow: ellipsis;
3348
+ font-size: var(--plinth-font-size-lg);
3349
+ font-weight: var(--plinth-font-weight-semibold);
3350
+ line-height: var(--plinth-font-leading-tight);
3351
+ letter-spacing: var(--plinth-font-tracking-tight);
3352
+ white-space: nowrap;
3353
+ }
3354
+
3355
+ /* Without a leading action or trailing actions, the heading keeps the text's distance from the edge. */
3356
+ .plinth-page-header-heading:first-child {
3357
+ margin-inline-start: var(--plinth-space-2);
3358
+ }
3359
+
3360
+ .plinth-page-header-heading:last-child {
3361
+ margin-inline-end: var(--plinth-space-2);
3362
+ }
3363
+
3364
+ @media (min-width: 768px) {
3365
+ .plinth-page-header {
3366
+ padding-inline: max(var(--plinth-space-4), env(safe-area-inset-left))
3367
+ max(var(--plinth-space-4), env(safe-area-inset-right));
3368
+ }
3369
+
3370
+ .plinth-page-header:dir(rtl) {
3371
+ padding-inline: max(var(--plinth-space-4), env(safe-area-inset-right))
3372
+ max(var(--plinth-space-4), env(safe-area-inset-left));
3373
+ }
3374
+ }
3375
+ }
3376
+
3377
+ /* pill-row.css */
3378
+ /*
3379
+ * Pill row: a row of pills for filters, segments or sub-pages.
3380
+ *
3381
+ * <div class="plinth-pill-row" role="group" aria-label="Status">
3382
+ * <button type="button" class="plinth-pill" aria-pressed="true">
3383
+ * <span class="plinth-pill-label">Open</span>
3384
+ * <span class="plinth-pill-count">12</span>
3385
+ * </button>
3386
+ * <button type="button" class="plinth-pill" aria-pressed="false">
3387
+ * <span class="plinth-pill-label">Closed</span>
3388
+ * </button>
3389
+ * </div>
3390
+ *
3391
+ * The root is a <div role="group">, named by aria-label (label in Blade and Vue) or by the app's
3392
+ * own aria-labelledby. A row of links that navigates is <nav class="plinth-pill-row"> instead,
3393
+ * with no role (nav in Blade and Vue), and its pills are links:
3394
+ *
3395
+ * <a class="plinth-pill" href="…" aria-current="page"><span class="plinth-pill-label">…</span></a>
3396
+ *
3397
+ * Each pill is its own component (<x-plinth::pill>, PlinthPill), so the app's attributes and
3398
+ * listeners land on the pill (ADR 2). The count is optional. plinth-pill-label has no rule of its
3399
+ * own: it is a hook for a project's styles.
3400
+ *
3401
+ * States, set by the app:
3402
+ * - selected: a button pill always prints aria-pressed, "true" or "false"; a link pill prints
3403
+ * aria-current="page" for the page being shown. Selected takes the selection tint
3404
+ * (accent-subtle, accent-text) inside a 2px accent edge (border-width.thick), where the others
3405
+ * have a 1px neutral one, so colour is not its only cue. A pressed button still answers hover
3406
+ * and press with a step on the tint (accent-subtle-hover, accent-subtle-active).
3407
+ * - disabled: `disabled` on a <button>; on a link, no href, role="link" and aria-disabled="true".
3408
+ * - hover only where the device can hover; the focus ring on :focus-visible.
3409
+ *
3410
+ * Layout: on a phone the row scrolls sideways, with no visible scrollbar, and snaps to the pills;
3411
+ * from 768px it wraps. The row's padding keeps the focus ring and the 40px touch target of the
3412
+ * pills inside the scrolling area, whatever --plinth-pill-height is (32px by default). A pill is
3413
+ * at least 40px wide.
3414
+ */
3415
+
3416
+ @layer plinth {
3417
+ .plinth-pill-row {
3418
+ box-sizing: border-box;
3419
+ display: flex;
3420
+ gap: var(--plinth-space-2);
3421
+ align-items: center;
3422
+ max-inline-size: 100%;
3423
+ padding-block: max(
3424
+ var(--plinth-space-1),
3425
+ (var(--plinth-touch-target) - var(--plinth-pill-height)) / 2
3426
+ );
3427
+ padding-inline: var(--plinth-space-1);
3428
+ margin: 0;
3429
+ overflow-x: auto;
3430
+ overscroll-behavior-inline: contain;
3431
+ scroll-snap-type: inline proximity;
3432
+ scroll-padding-inline: var(--plinth-space-1);
3164
3433
  scrollbar-width: none;
3165
3434
  }
3166
3435
 
@@ -3306,7 +3575,7 @@
3306
3575
 
3307
3576
  /* section-menu.css */
3308
3577
  /*
3309
- * Section menu: the row under the header that switches between the sections of a page.
3578
+ * Section menu: the row under the page header that switches between the sections of a page.
3310
3579
  *
3311
3580
  * <nav class="plinth-section-menu" aria-label="Project">
3312
3581
  * <a class="plinth-section-menu-item" href="/overview" aria-current="page">
@@ -3340,15 +3609,16 @@
3340
3609
  * - hover only where the device can hover; the focus ring on :focus-visible.
3341
3610
  *
3342
3611
  * Layout: a row --plinth-section-menu-height (56px) tall, on the surface with a divider below,
3343
- * the same inline padding as the header and clear of the notch on a phone held sideways. Its
3612
+ * the same inline padding as the page header and clear of the notch on a phone held sideways. Its
3344
3613
  * items are --plinth-section-menu-item-height (40px) tall, never less than the 40px touch
3345
3614
  * target, and at least 40px wide. The row never wraps: when the items don't fit it scrolls
3346
3615
  * sideways and snaps to them, with a thin scrollbar for a mouse and none on a touch screen; the
3347
3616
  * row's block padding keeps the focus ring inside it. The app scrolls a current item that starts
3348
3617
  * out of view into view.
3349
- * Placement is the app's: put it under the header, or make it sticky below it with
3350
- * position: sticky, inset-block-start: calc(var(--plinth-header-height) +
3351
- * env(safe-area-inset-top)) and z-index: var(--plinth-layer-sticky).
3618
+ * Placement is the app's: put it under the page header, or make it sticky below it with
3619
+ * position: sticky, inset-block-start: calc(var(--plinth-page-header-offset) +
3620
+ * var(--plinth-page-header-height)), plus env(safe-area-inset-top) under the screen's top bar,
3621
+ * and z-index: var(--plinth-layer-sticky).
3352
3622
  */
3353
3623
 
3354
3624
  @layer plinth {
@@ -3771,3 +4041,406 @@
3771
4041
  }
3772
4042
  }
3773
4043
  }
4044
+
4045
+ /* text-field.css */
4046
+ /*
4047
+ * Text field: one line of text, with its label, an optional description and an error.
4048
+ *
4049
+ * <div class="plinth-text-field" data-size="md">
4050
+ * <label class="plinth-text-field-label" for="email">Email</label>
4051
+ * <div class="plinth-text-field-control">
4052
+ * <span class="plinth-icon" data-size="sm" aria-hidden="true"><svg>…</svg></span>
4053
+ * <input type="email" class="plinth-text-field-input" id="email"
4054
+ * aria-describedby="email-description email-error" aria-invalid="true">
4055
+ * <span class="plinth-text-field-trailing">…</span>
4056
+ * </div>
4057
+ * <p class="plinth-text-field-description" id="email-description">For receipts only.</p>
4058
+ * <p class="plinth-text-field-error" id="email-error">Enter an email address.</p>
4059
+ * </div>
4060
+ *
4061
+ * The input needs an id: the label points at it with for, and the description's and error's ids
4062
+ * are the input's id with -description and -error. The icon, the trailing slot, the description
4063
+ * and the error are optional; aria-describedby lists the description, then the error, and is left
4064
+ * out when there is neither.
4065
+ *
4066
+ * Attributes go to the input, not the root (name, value, placeholder, autocomplete, required,
4067
+ * wire:model, x-model, data-*, aria-*), so the app talks to the input; only a project's class is
4068
+ * added to the root's.
4069
+ *
4070
+ * - type (on the input): text by default; email, url, tel, password, number or search. A search
4071
+ * field takes a search icon and, in the trailing slot, a clear button (a small ghost icon
4072
+ * button) that the app shows when there is text and wires up. With a trailing slot, the
4073
+ * browser's own clear button is hidden, so there is only one.
4074
+ * - the leading icon is 16px and decorative (aria-hidden); the label names the field.
4075
+ * - the trailing slot holds one small control or hint, in the room the field keeps at its end
4076
+ * (--plinth-text-field-trailing-size, 32px): a small icon button, or a keyboard hint as <kbd>.
4077
+ * - data-label="hidden" on the root hides the label visually; screen readers still read it. Use it
4078
+ * only where the field's purpose is plain without it, such as a search box with its icon.
4079
+ *
4080
+ * Sizes (data-size): md 32px (default, also when missing) and sm 28px. On a touch screen both are
4081
+ * 40px tall. The text is 16px on a phone, so iOS doesn't zoom into the field, and from 768px
4082
+ * up it is 14px (md) or 13px (sm).
4083
+ *
4084
+ * States:
4085
+ * - error: aria-invalid="true" on the input and the error message under it; the edge turns
4086
+ * danger and thick, so it isn't only a colour, and the message says what to fix.
4087
+ * - disabled: `disabled` on the input; the field is faded and can't be focused.
4088
+ * - read-only: `readonly` on the input; it keeps its text and focus, without the field's fill
4089
+ * and with a dashed edge.
4090
+ * - the focus ring on the input's :focus-visible.
4091
+ */
4092
+
4093
+ @layer plinth {
4094
+ .plinth-text-field {
4095
+ --plinth-text-field-height: var(--plinth-control-md);
4096
+ --plinth-text-field-text: var(--plinth-font-size-md);
4097
+
4098
+ display: grid;
4099
+ gap: var(--plinth-space-2);
4100
+ min-inline-size: 0;
4101
+ font-family: var(--plinth-font-family-body);
4102
+ }
4103
+
4104
+ .plinth-text-field[data-size='sm'] {
4105
+ --plinth-text-field-height: var(--plinth-control-sm);
4106
+ --plinth-text-field-text: var(--plinth-font-size-sm);
4107
+ }
4108
+
4109
+ .plinth-text-field-label {
4110
+ font-size: var(--plinth-font-size-sm);
4111
+ font-weight: var(--plinth-font-weight-medium);
4112
+ line-height: var(--plinth-font-leading-normal);
4113
+ color: var(--plinth-color-text-muted);
4114
+ overflow-wrap: anywhere;
4115
+ }
4116
+
4117
+ /* Hidden from sight, still read by screen readers. */
4118
+ .plinth-text-field[data-label='hidden'] .plinth-text-field-label {
4119
+ position: absolute;
4120
+ inline-size: 1px;
4121
+ block-size: 1px;
4122
+ overflow: hidden;
4123
+ white-space: nowrap;
4124
+ clip-path: inset(50%);
4125
+ }
4126
+
4127
+ .plinth-text-field-control {
4128
+ position: relative;
4129
+ display: flex;
4130
+ align-items: center;
4131
+ min-inline-size: 0;
4132
+ }
4133
+
4134
+ .plinth-text-field-input {
4135
+ box-sizing: border-box;
4136
+ flex: 1;
4137
+ min-inline-size: 0;
4138
+ min-block-size: var(--plinth-text-field-height);
4139
+ padding-block: 0;
4140
+ padding-inline: var(--plinth-space-3);
4141
+ margin: 0;
4142
+ font-family: inherit;
4143
+ font-size: var(--plinth-font-size-field);
4144
+ line-height: var(--plinth-font-leading-tight);
4145
+ color: var(--plinth-color-text);
4146
+ appearance: none;
4147
+ background-color: var(--plinth-color-surface-sunken);
4148
+ border: var(--plinth-border-width-thin) solid var(--plinth-color-border-strong);
4149
+ border-radius: var(--plinth-radius-control);
4150
+ transition: border-color var(--plinth-duration-fast) var(--plinth-easing-standard);
4151
+ }
4152
+
4153
+ .plinth-text-field-input::placeholder {
4154
+ color: var(--plinth-color-text-subtle);
4155
+ opacity: 1;
4156
+ }
4157
+
4158
+ @media (hover: hover) {
4159
+ .plinth-text-field-input:hover:not(:disabled, :read-only, [aria-invalid='true']) {
4160
+ border-color: var(--plinth-color-text-subtle);
4161
+ }
4162
+ }
4163
+
4164
+ .plinth-text-field-input:focus-visible {
4165
+ outline: var(--plinth-focus-ring-width) solid var(--plinth-color-focus-ring);
4166
+ outline-offset: var(--plinth-focus-ring-offset);
4167
+ }
4168
+
4169
+ .plinth-text-field-input[aria-invalid='true'] {
4170
+ border-color: var(--plinth-color-danger);
4171
+ border-width: var(--plinth-border-width-thick);
4172
+ }
4173
+
4174
+ .plinth-text-field-input:disabled {
4175
+ cursor: not-allowed;
4176
+ }
4177
+
4178
+ /* Without the fill, and with a dashed edge, so it doesn't read as a field to type in. */
4179
+ .plinth-text-field-input:read-only:not(:disabled) {
4180
+ background-color: transparent;
4181
+ border-style: dashed;
4182
+ }
4183
+
4184
+ .plinth-text-field-control:has(.plinth-text-field-input:disabled) {
4185
+ opacity: var(--plinth-opacity-disabled);
4186
+ }
4187
+
4188
+ /* The leading icon sits over the input's start, which keeps room for it. */
4189
+ .plinth-text-field-control > .plinth-icon {
4190
+ position: absolute;
4191
+ inset-inline-start: var(--plinth-space-3);
4192
+ color: var(--plinth-color-text-subtle);
4193
+ pointer-events: none;
4194
+ }
4195
+
4196
+ .plinth-text-field-control:has(> .plinth-icon) > .plinth-text-field-input {
4197
+ padding-inline-start: calc(
4198
+ var(--plinth-space-3) + var(--plinth-size-icon-sm) + var(--plinth-space-2)
4199
+ );
4200
+ }
4201
+
4202
+ .plinth-text-field-trailing {
4203
+ position: absolute;
4204
+ inset-inline-end: var(--plinth-space-1);
4205
+ display: flex;
4206
+ gap: var(--plinth-space-1);
4207
+ align-items: center;
4208
+ justify-content: center;
4209
+ min-inline-size: var(--plinth-text-field-trailing-size);
4210
+ color: var(--plinth-color-text-subtle);
4211
+ }
4212
+
4213
+ .plinth-text-field-control:has(> .plinth-text-field-trailing) > .plinth-text-field-input {
4214
+ padding-inline-end: calc(var(--plinth-space-1) + var(--plinth-text-field-trailing-size));
4215
+ }
4216
+
4217
+ /* With its own clear button in the trailing slot, the browser's is one too many. */
4218
+ .plinth-text-field-control:has(> .plinth-text-field-trailing)
4219
+ > .plinth-text-field-input::-webkit-search-cancel-button {
4220
+ display: none;
4221
+ }
4222
+
4223
+ .plinth-text-field-trailing kbd {
4224
+ box-sizing: border-box;
4225
+ min-inline-size: var(--plinth-size-icon-md);
4226
+ padding-block: 0;
4227
+ padding-inline: var(--plinth-space-1);
4228
+ font-family: var(--plinth-font-family-mono);
4229
+ font-size: var(--plinth-font-size-xs);
4230
+ line-height: var(--plinth-font-leading-normal);
4231
+ color: var(--plinth-color-text-muted);
4232
+ text-align: center;
4233
+ border: var(--plinth-border-width-thin) solid var(--plinth-color-border-strong);
4234
+ border-radius: var(--plinth-radius-control);
4235
+ }
4236
+
4237
+ .plinth-text-field-description,
4238
+ .plinth-text-field-error {
4239
+ margin: 0;
4240
+ font-size: var(--plinth-font-size-sm);
4241
+ line-height: var(--plinth-font-leading-normal);
4242
+ overflow-wrap: anywhere;
4243
+ }
4244
+
4245
+ .plinth-text-field-description {
4246
+ color: var(--plinth-color-text-muted);
4247
+ }
4248
+
4249
+ .plinth-text-field-error {
4250
+ color: var(--plinth-color-danger-text);
4251
+ }
4252
+
4253
+ @media (pointer: coarse) {
4254
+ .plinth-text-field-input {
4255
+ min-block-size: var(--plinth-touch-target);
4256
+ }
4257
+ }
4258
+
4259
+ @media (min-width: 768px) {
4260
+ .plinth-text-field-input {
4261
+ font-size: var(--plinth-text-field-text);
4262
+ }
4263
+ }
4264
+
4265
+ @media (forced-colors: active) {
4266
+ .plinth-text-field-control:has(.plinth-text-field-input:disabled) {
4267
+ color: GrayText;
4268
+ }
4269
+
4270
+ .plinth-text-field-input:disabled {
4271
+ color: GrayText;
4272
+ border-color: GrayText;
4273
+ }
4274
+ }
4275
+ }
4276
+
4277
+ /* textarea.css */
4278
+ /*
4279
+ * Textarea: several lines of text, with its label, an optional description and an error. With
4280
+ * grow, it grows with its content.
4281
+ *
4282
+ * <div class="plinth-textarea">
4283
+ * <label class="plinth-textarea-label" for="reply">Reply</label>
4284
+ * <textarea class="plinth-textarea-input" id="reply" rows="3"
4285
+ * aria-describedby="reply-description reply-error" aria-invalid="true"></textarea>
4286
+ * <p class="plinth-textarea-description" id="reply-description">The customer sees it.</p>
4287
+ * <p class="plinth-textarea-error" id="reply-error">Write a reply first.</p>
4288
+ * </div>
4289
+ *
4290
+ * The textarea needs an id: the label points at it with for, and the description's and error's
4291
+ * ids are the textarea's id with -description and -error. The description and the error are
4292
+ * optional; aria-describedby lists the description, then the error, and is left out when there is
4293
+ * neither. rows is 3 by default.
4294
+ *
4295
+ * Attributes go to the textarea, not the root (name, placeholder, maxlength, required,
4296
+ * wire:model, x-model, data-*, aria-*), so the app talks to the textarea; only a project's class
4297
+ * is added to the root's.
4298
+ *
4299
+ * - grow: data-grow on the textarea, with its rows and maximum as custom properties:
4300
+ * <textarea class="plinth-textarea-input" id="reply" rows="3" data-grow
4301
+ * style="--plinth-textarea-rows: 3; --plinth-textarea-max-rows: 8"></textarea>
4302
+ * It starts at rows lines and grows with its content (field-sizing: content) up to max-rows
4303
+ * (8 by default), then scrolls. Where the browser has no field-sizing it keeps rows lines and
4304
+ * the person can resize it, as without grow.
4305
+ * - data-label="hidden" on the root hides the label visually; screen readers still read it. Use it
4306
+ * only where the field's purpose is plain without it, such as a reply box under a thread.
4307
+ *
4308
+ * The text is 16px on a phone, so iOS doesn't zoom into the field, and 14px from 768px up.
4309
+ *
4310
+ * States:
4311
+ * - error: aria-invalid="true" on the textarea and the error message under it; the edge turns
4312
+ * danger and thick, so it isn't only a colour, and the message says what to fix.
4313
+ * - disabled: `disabled` on the textarea; the field is faded and can't be focused.
4314
+ * - read-only: `readonly` on the textarea; it keeps its text and focus, without the field's fill
4315
+ * and with a dashed edge.
4316
+ * - the focus ring on the textarea's :focus-visible.
4317
+ */
4318
+
4319
+ @layer plinth {
4320
+ .plinth-textarea {
4321
+ display: grid;
4322
+ gap: var(--plinth-space-2);
4323
+ min-inline-size: 0;
4324
+ font-family: var(--plinth-font-family-body);
4325
+ }
4326
+
4327
+ .plinth-textarea-label {
4328
+ font-size: var(--plinth-font-size-sm);
4329
+ font-weight: var(--plinth-font-weight-medium);
4330
+ line-height: var(--plinth-font-leading-normal);
4331
+ color: var(--plinth-color-text-muted);
4332
+ overflow-wrap: anywhere;
4333
+ }
4334
+
4335
+ /* Hidden from sight, still read by screen readers. */
4336
+ .plinth-textarea[data-label='hidden'] .plinth-textarea-label {
4337
+ position: absolute;
4338
+ inline-size: 1px;
4339
+ block-size: 1px;
4340
+ overflow: hidden;
4341
+ white-space: nowrap;
4342
+ clip-path: inset(50%);
4343
+ }
4344
+
4345
+ .plinth-textarea-input {
4346
+ box-sizing: border-box;
4347
+ inline-size: 100%;
4348
+ min-block-size: var(--plinth-touch-target);
4349
+ padding-block: var(--plinth-space-2);
4350
+ padding-inline: var(--plinth-space-3);
4351
+ margin: 0;
4352
+ font-family: inherit;
4353
+ font-size: var(--plinth-font-size-field);
4354
+ line-height: var(--plinth-font-leading-normal);
4355
+ color: var(--plinth-color-text);
4356
+ resize: vertical;
4357
+ background-color: var(--plinth-color-surface-sunken);
4358
+ border: var(--plinth-border-width-thin) solid var(--plinth-color-border-strong);
4359
+ border-radius: var(--plinth-radius-control);
4360
+ transition: border-color var(--plinth-duration-fast) var(--plinth-easing-standard);
4361
+ }
4362
+
4363
+ .plinth-textarea-input::placeholder {
4364
+ color: var(--plinth-color-text-subtle);
4365
+ opacity: 1;
4366
+ }
4367
+
4368
+ @media (hover: hover) {
4369
+ .plinth-textarea-input:hover:not(:disabled, :read-only, [aria-invalid='true']) {
4370
+ border-color: var(--plinth-color-text-subtle);
4371
+ }
4372
+ }
4373
+
4374
+ .plinth-textarea-input:focus-visible {
4375
+ outline: var(--plinth-focus-ring-width) solid var(--plinth-color-focus-ring);
4376
+ outline-offset: var(--plinth-focus-ring-offset);
4377
+ }
4378
+
4379
+ .plinth-textarea-input[aria-invalid='true'] {
4380
+ border-color: var(--plinth-color-danger);
4381
+ border-width: var(--plinth-border-width-thick);
4382
+ }
4383
+
4384
+ .plinth-textarea-input:disabled {
4385
+ cursor: not-allowed;
4386
+ resize: none;
4387
+ opacity: var(--plinth-opacity-disabled);
4388
+ }
4389
+
4390
+ /* Without the fill, and with a dashed edge, so it doesn't read as a field to type in. */
4391
+ .plinth-textarea-input:read-only:not(:disabled) {
4392
+ background-color: transparent;
4393
+ border-style: dashed;
4394
+ }
4395
+
4396
+ /*
4397
+ * Grow: from rows lines to max-rows lines (in the textarea's own line height, with its padding
4398
+ * and edge), then it scrolls.
4399
+ */
4400
+ @supports (field-sizing: content) {
4401
+ .plinth-textarea-input[data-grow] {
4402
+ field-sizing: content;
4403
+ min-block-size: max(
4404
+ var(--plinth-touch-target),
4405
+ calc(
4406
+ var(--plinth-textarea-rows, 3) * 1lh + 2 * var(--plinth-space-2) + 2 *
4407
+ var(--plinth-border-width-thin)
4408
+ )
4409
+ );
4410
+ max-block-size: calc(
4411
+ var(--plinth-textarea-max-rows, 8) * 1lh + 2 * var(--plinth-space-2) + 2 *
4412
+ var(--plinth-border-width-thin)
4413
+ );
4414
+ resize: none;
4415
+ }
4416
+ }
4417
+
4418
+ .plinth-textarea-description,
4419
+ .plinth-textarea-error {
4420
+ margin: 0;
4421
+ font-size: var(--plinth-font-size-sm);
4422
+ line-height: var(--plinth-font-leading-normal);
4423
+ overflow-wrap: anywhere;
4424
+ }
4425
+
4426
+ .plinth-textarea-description {
4427
+ color: var(--plinth-color-text-muted);
4428
+ }
4429
+
4430
+ .plinth-textarea-error {
4431
+ color: var(--plinth-color-danger-text);
4432
+ }
4433
+
4434
+ @media (min-width: 768px) {
4435
+ .plinth-textarea-input {
4436
+ font-size: var(--plinth-font-size-md);
4437
+ }
4438
+ }
4439
+
4440
+ @media (forced-colors: active) {
4441
+ .plinth-textarea-input:disabled {
4442
+ color: GrayText;
4443
+ border-color: GrayText;
4444
+ }
4445
+ }
4446
+ }