@ptbdynamic/plinth 0.0.0-stage → 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 (150) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +258 -2
  3. package/package.json +99 -4
  4. package/packages/css/dist/avatar.css +122 -0
  5. package/packages/css/dist/badge.css +76 -0
  6. package/packages/css/dist/button.css +273 -0
  7. package/packages/css/dist/callout.css +143 -0
  8. package/packages/css/dist/code-block.css +283 -0
  9. package/packages/css/dist/confirm-dialog.css +183 -0
  10. package/packages/css/dist/data-table.css +470 -0
  11. package/packages/css/dist/dialog.css +113 -0
  12. package/packages/css/dist/drawer.css +156 -0
  13. package/packages/css/dist/fonts/geist/LICENSE +93 -0
  14. package/packages/css/dist/fonts/geist/geist-cyrillic-ext-wght-normal.woff2 +0 -0
  15. package/packages/css/dist/fonts/geist/geist-cyrillic-wght-normal.woff2 +0 -0
  16. package/packages/css/dist/fonts/geist/geist-latin-ext-wght-normal.woff2 +0 -0
  17. package/packages/css/dist/fonts/geist/geist-latin-wght-normal.woff2 +0 -0
  18. package/packages/css/dist/fonts/geist/geist-vietnamese-wght-normal.woff2 +0 -0
  19. package/packages/css/dist/fonts/geist-mono/LICENSE +93 -0
  20. package/packages/css/dist/fonts/geist-mono/geist-mono-cyrillic-ext-wght-normal.woff2 +0 -0
  21. package/packages/css/dist/fonts/geist-mono/geist-mono-cyrillic-wght-normal.woff2 +0 -0
  22. package/packages/css/dist/fonts/geist-mono/geist-mono-latin-ext-wght-normal.woff2 +0 -0
  23. package/packages/css/dist/fonts/geist-mono/geist-mono-latin-wght-normal.woff2 +0 -0
  24. package/packages/css/dist/fonts/geist-mono/geist-mono-symbols2-wght-normal.woff2 +0 -0
  25. package/packages/css/dist/fonts/geist-mono/geist-mono-vietnamese-wght-normal.woff2 +0 -0
  26. package/packages/css/dist/fonts/index.css +109 -0
  27. package/packages/css/dist/header.css +110 -0
  28. package/packages/css/dist/icon.css +51 -0
  29. package/packages/css/dist/list-card.css +76 -0
  30. package/packages/css/dist/list-row.css +164 -0
  31. package/packages/css/dist/menu-sheet.css +84 -0
  32. package/packages/css/dist/menu.css +245 -0
  33. package/packages/css/dist/modal.css +367 -0
  34. package/packages/css/dist/page-header.css +124 -0
  35. package/packages/css/dist/pill-row.css +197 -0
  36. package/packages/css/dist/plinth.css +4446 -0
  37. package/packages/css/dist/section-menu.css +226 -0
  38. package/packages/css/dist/sheet.css +239 -0
  39. package/packages/css/dist/text-field.css +230 -0
  40. package/packages/css/dist/textarea.css +169 -0
  41. package/packages/css/dist/tokens.css +413 -0
  42. package/packages/react/dist/PlinthAvatar.d.ts +17 -0
  43. package/packages/react/dist/PlinthBadge.d.ts +7 -0
  44. package/packages/react/dist/PlinthButton.d.ts +29 -0
  45. package/packages/react/dist/PlinthCallout.d.ts +16 -0
  46. package/packages/react/dist/PlinthCodeBlock.d.ts +24 -0
  47. package/packages/react/dist/PlinthConfirmDialog.d.ts +38 -0
  48. package/packages/react/dist/PlinthDataTable.d.ts +11 -0
  49. package/packages/react/dist/PlinthDataTableCell.d.ts +15 -0
  50. package/packages/react/dist/PlinthDataTableColumn.d.ts +13 -0
  51. package/packages/react/dist/PlinthDataTableEmpty.d.ts +9 -0
  52. package/packages/react/dist/PlinthDataTableRow.d.ts +9 -0
  53. package/packages/react/dist/PlinthDataTableSort.d.ts +4 -0
  54. package/packages/react/dist/PlinthDialog.d.ts +26 -0
  55. package/packages/react/dist/PlinthDrawer.d.ts +29 -0
  56. package/packages/react/dist/PlinthIcon.d.ts +10 -0
  57. package/packages/react/dist/PlinthIconButton.d.ts +26 -0
  58. package/packages/react/dist/PlinthListCard.d.ts +14 -0
  59. package/packages/react/dist/PlinthListRow.d.ts +29 -0
  60. package/packages/react/dist/PlinthMenu.d.ts +12 -0
  61. package/packages/react/dist/PlinthMenuGroup.d.ts +9 -0
  62. package/packages/react/dist/PlinthMenuItem.d.ts +26 -0
  63. package/packages/react/dist/PlinthMenuSeparator.d.ts +4 -0
  64. package/packages/react/dist/PlinthMenuSheet.d.ts +24 -0
  65. package/packages/react/dist/PlinthPageHeader.d.ts +17 -0
  66. package/packages/react/dist/PlinthPill.d.ts +17 -0
  67. package/packages/react/dist/PlinthPillRow.d.ts +13 -0
  68. package/packages/react/dist/PlinthSectionMenu.d.ts +11 -0
  69. package/packages/react/dist/PlinthSectionMenuItem.d.ts +20 -0
  70. package/packages/react/dist/PlinthSheet.d.ts +26 -0
  71. package/packages/react/dist/PlinthTextField.d.ts +24 -0
  72. package/packages/react/dist/PlinthTextarea.d.ts +24 -0
  73. package/packages/react/dist/avatar.d.ts +12 -0
  74. package/packages/react/dist/button.d.ts +21 -0
  75. package/packages/react/dist/callout.d.ts +19 -0
  76. package/packages/react/dist/code-block.d.ts +16 -0
  77. package/packages/react/dist/confirm-dialog.d.ts +23 -0
  78. package/packages/react/dist/data-table.d.ts +15 -0
  79. package/packages/react/dist/dialog.d.ts +10 -0
  80. package/packages/react/dist/drawer.d.ts +20 -0
  81. package/packages/react/dist/heading.d.ts +5 -0
  82. package/packages/react/dist/icon.d.ts +5 -0
  83. package/packages/react/dist/index.d.ts +49 -0
  84. package/packages/react/dist/index.js +1326 -0
  85. package/packages/react/dist/list-row.d.ts +5 -0
  86. package/packages/react/dist/markup.d.ts +9 -0
  87. package/packages/react/dist/menu.d.ts +7 -0
  88. package/packages/react/dist/page-header.d.ts +5 -0
  89. package/packages/react/dist/sheet.d.ts +12 -0
  90. package/packages/react/dist/size.d.ts +13 -0
  91. package/packages/react/dist/swipe.d.ts +15 -0
  92. package/packages/react/dist/text-field.d.ts +10 -0
  93. package/packages/react/dist/tones.d.ts +5 -0
  94. package/packages/tokens/dist/index.d.ts +230 -0
  95. package/packages/tokens/dist/index.js +4091 -0
  96. package/packages/tokens/dist/tailwind-theme.css +80 -0
  97. package/packages/tokens/dist/tokens.css +413 -0
  98. package/packages/tokens/dist/tokens.json +3875 -0
  99. package/packages/vue/dist/PlinthAvatar.vue.d.ts +24 -0
  100. package/packages/vue/dist/PlinthBadge.vue.d.ts +21 -0
  101. package/packages/vue/dist/PlinthButton.vue.d.ts +42 -0
  102. package/packages/vue/dist/PlinthCallout.vue.d.ts +31 -0
  103. package/packages/vue/dist/PlinthCodeBlock.vue.d.ts +49 -0
  104. package/packages/vue/dist/PlinthConfirmDialog.vue.d.ts +51 -0
  105. package/packages/vue/dist/PlinthDataTable.vue.d.ts +21 -0
  106. package/packages/vue/dist/PlinthDataTableCell.vue.d.ts +29 -0
  107. package/packages/vue/dist/PlinthDataTableColumn.vue.d.ts +26 -0
  108. package/packages/vue/dist/PlinthDataTableEmpty.vue.d.ts +19 -0
  109. package/packages/vue/dist/PlinthDataTableRow.vue.d.ts +19 -0
  110. package/packages/vue/dist/PlinthDataTableSort.vue.d.ts +13 -0
  111. package/packages/vue/dist/PlinthDialog.vue.d.ts +45 -0
  112. package/packages/vue/dist/PlinthDrawer.vue.d.ts +48 -0
  113. package/packages/vue/dist/PlinthIcon.vue.d.ts +23 -0
  114. package/packages/vue/dist/PlinthIconButton.vue.d.ts +37 -0
  115. package/packages/vue/dist/PlinthListCard.vue.d.ts +26 -0
  116. package/packages/vue/dist/PlinthListRow.vue.d.ts +38 -0
  117. package/packages/vue/dist/PlinthMenu.vue.d.ts +24 -0
  118. package/packages/vue/dist/PlinthMenuGroup.vue.d.ts +17 -0
  119. package/packages/vue/dist/PlinthMenuItem.vue.d.ts +34 -0
  120. package/packages/vue/dist/PlinthMenuSeparator.vue.d.ts +3 -0
  121. package/packages/vue/dist/PlinthMenuSheet.vue.d.ts +48 -0
  122. package/packages/vue/dist/PlinthPageHeader.vue.d.ts +27 -0
  123. package/packages/vue/dist/PlinthPill.vue.d.ts +28 -0
  124. package/packages/vue/dist/PlinthPillRow.vue.d.ts +22 -0
  125. package/packages/vue/dist/PlinthSectionMenu.vue.d.ts +22 -0
  126. package/packages/vue/dist/PlinthSectionMenuItem.vue.d.ts +28 -0
  127. package/packages/vue/dist/PlinthSheet.vue.d.ts +45 -0
  128. package/packages/vue/dist/PlinthTextField.vue.d.ts +51 -0
  129. package/packages/vue/dist/PlinthTextarea.vue.d.ts +42 -0
  130. package/packages/vue/dist/avatar.d.ts +12 -0
  131. package/packages/vue/dist/button.d.ts +5 -0
  132. package/packages/vue/dist/callout.d.ts +19 -0
  133. package/packages/vue/dist/code-block.d.ts +16 -0
  134. package/packages/vue/dist/confirm-dialog.d.ts +23 -0
  135. package/packages/vue/dist/data-table.d.ts +15 -0
  136. package/packages/vue/dist/dialog.d.ts +10 -0
  137. package/packages/vue/dist/drawer.d.ts +20 -0
  138. package/packages/vue/dist/heading.d.ts +5 -0
  139. package/packages/vue/dist/icon.d.ts +5 -0
  140. package/packages/vue/dist/index.d.ts +53 -0
  141. package/packages/vue/dist/index.js +1732 -0
  142. package/packages/vue/dist/list-row.d.ts +5 -0
  143. package/packages/vue/dist/menu.d.ts +8 -0
  144. package/packages/vue/dist/page-header.d.ts +5 -0
  145. package/packages/vue/dist/sheet.d.ts +12 -0
  146. package/packages/vue/dist/size.d.ts +13 -0
  147. package/packages/vue/dist/slots.d.ts +3 -0
  148. package/packages/vue/dist/swipe.d.ts +12 -0
  149. package/packages/vue/dist/text-field.d.ts +10 -0
  150. package/packages/vue/dist/tones.d.ts +5 -0
@@ -0,0 +1,226 @@
1
+ /*
2
+ * Section menu: the row under the page header that switches between the sections of a page.
3
+ *
4
+ * <nav class="plinth-section-menu" aria-label="Project">
5
+ * <a class="plinth-section-menu-item" href="/overview" aria-current="page">
6
+ * <span class="plinth-section-menu-label">Overview</span>
7
+ * </a>
8
+ * <a class="plinth-section-menu-item" href="/tasks">
9
+ * <span class="plinth-section-menu-label">Tasks</span>
10
+ * <span class="plinth-section-menu-count">8</span>
11
+ * </a>
12
+ * </nav>
13
+ *
14
+ * The root is a <nav>, named by aria-label (label in Blade and Vue) or by the app's own
15
+ * aria-labelledby, so it reads apart from the page's other navigation. Its items are links. A
16
+ * menu that switches views in place, without navigating, has buttons instead, and its root is
17
+ * <div class="plinth-section-menu" role="group"> (nav set to false in Blade and Vue):
18
+ *
19
+ * <button type="button" class="plinth-section-menu-item" aria-pressed="true">…</button>
20
+ *
21
+ * Each item is its own component (<x-plinth::section-menu-item>, PlinthSectionMenuItem), so the
22
+ * app's attributes and listeners land on the item (ADR 2). The count is optional.
23
+ * plinth-section-menu-label has no rule of its own: it is a hook for a project's styles.
24
+ *
25
+ * States, set by the app:
26
+ * - current: a link prints aria-current="page" for the section being shown; a button always
27
+ * prints aria-pressed, "true" or "false". The current item takes the selection tint
28
+ * (accent-subtle, accent-text) inside a 2px accent edge (border-width.thick), so colour is not
29
+ * its only cue; the others are muted text on no surface, like a ghost button. A pressed button
30
+ * still answers hover and press with a step on the tint (accent-subtle-hover,
31
+ * accent-subtle-active).
32
+ * - disabled: `disabled` on a <button>; on a link, no href, role="link" and aria-disabled="true".
33
+ * - hover only where the device can hover; the focus ring on :focus-visible.
34
+ *
35
+ * Layout: a row --plinth-section-menu-height (56px) tall, on the surface with a divider below,
36
+ * the same inline padding as the page header and clear of the notch on a phone held sideways. Its
37
+ * items are --plinth-section-menu-item-height (40px) tall, never less than the 40px touch
38
+ * target, and at least 40px wide. The row never wraps: when the items don't fit it scrolls
39
+ * sideways and snaps to them, with a thin scrollbar for a mouse and none on a touch screen; the
40
+ * row's block padding keeps the focus ring inside it. The app scrolls a current item that starts
41
+ * out of view into view.
42
+ * Placement is the app's: put it under the page header, or make it sticky below it with
43
+ * position: sticky, inset-block-start: calc(var(--plinth-page-header-offset) +
44
+ * var(--plinth-page-header-height)), plus env(safe-area-inset-top) under the screen's top bar,
45
+ * and z-index: var(--plinth-layer-sticky).
46
+ */
47
+
48
+ @layer plinth {
49
+ .plinth-section-menu {
50
+ box-sizing: border-box;
51
+ display: flex;
52
+ gap: var(--plinth-space-1);
53
+ align-items: center;
54
+ min-block-size: var(--plinth-section-menu-height);
55
+
56
+ /* What the item and the divider leave of the row's height, so the row is exactly its token. */
57
+ padding-block: calc(
58
+ (
59
+ var(--plinth-section-menu-height) - var(--plinth-section-menu-item-height) -
60
+ var(--plinth-border-width-thin)
61
+ ) /
62
+ 2
63
+ );
64
+ padding-inline: max(var(--plinth-space-2), env(safe-area-inset-left))
65
+ max(var(--plinth-space-2), env(safe-area-inset-right));
66
+ margin: 0;
67
+ overflow-x: auto;
68
+ overscroll-behavior-inline: contain;
69
+ font-family: var(--plinth-font-family-body);
70
+ scroll-snap-type: inline proximity;
71
+ scroll-padding-inline: max(var(--plinth-space-2), env(safe-area-inset-left))
72
+ max(var(--plinth-space-2), env(safe-area-inset-right));
73
+ scrollbar-width: none;
74
+ background-color: var(--plinth-color-surface);
75
+ border-block-end: var(--plinth-border-width-thin) solid var(--plinth-color-divider);
76
+ }
77
+
78
+ .plinth-section-menu:dir(rtl) {
79
+ padding-inline: max(var(--plinth-space-2), env(safe-area-inset-right))
80
+ max(var(--plinth-space-2), env(safe-area-inset-left));
81
+ scroll-padding-inline: max(var(--plinth-space-2), env(safe-area-inset-right))
82
+ max(var(--plinth-space-2), env(safe-area-inset-left));
83
+ }
84
+
85
+ /* A mouse without a sideways wheel needs a scrollbar to reach the items that don't fit. */
86
+ @media (hover: hover) and (pointer: fine) {
87
+ .plinth-section-menu {
88
+ scrollbar-width: thin;
89
+ }
90
+ }
91
+
92
+ .plinth-section-menu-item {
93
+ box-sizing: border-box;
94
+ display: inline-flex;
95
+ flex: none;
96
+ gap: var(--plinth-space-2);
97
+ align-items: center;
98
+ justify-content: center;
99
+ min-inline-size: var(--plinth-touch-target);
100
+ min-block-size: max(var(--plinth-section-menu-item-height), var(--plinth-touch-target));
101
+ padding-block: 0;
102
+ padding-inline: var(--plinth-space-3);
103
+ margin: 0;
104
+ font-family: inherit;
105
+ font-size: var(--plinth-font-size-md);
106
+ font-weight: var(--plinth-font-weight-medium);
107
+ line-height: var(--plinth-font-leading-tight);
108
+ color: var(--plinth-color-text-muted);
109
+ white-space: nowrap;
110
+ text-decoration: none;
111
+ cursor: pointer;
112
+ user-select: none;
113
+ scroll-snap-align: start;
114
+ background-color: transparent;
115
+ border: var(--plinth-border-width-thin) solid transparent;
116
+ border-radius: var(--plinth-radius-control);
117
+ transition:
118
+ background-color var(--plinth-duration-fast) var(--plinth-easing-standard),
119
+ border-color var(--plinth-duration-fast) var(--plinth-easing-standard),
120
+ box-shadow var(--plinth-duration-fast) var(--plinth-easing-standard),
121
+ color var(--plinth-duration-fast) var(--plinth-easing-standard);
122
+ }
123
+
124
+ @media (hover: hover) {
125
+ a.plinth-section-menu-item[href]:not([aria-current='page']):hover,
126
+ button.plinth-section-menu-item:enabled:not([aria-pressed='true']):hover {
127
+ color: var(--plinth-color-text);
128
+ background-color: var(--plinth-color-surface-hover);
129
+ }
130
+ }
131
+
132
+ a.plinth-section-menu-item[href]:not([aria-current='page']):active,
133
+ button.plinth-section-menu-item:enabled:not([aria-pressed='true']):active {
134
+ color: var(--plinth-color-text);
135
+ background-color: var(--plinth-color-surface-sunken);
136
+ }
137
+
138
+ /* The border and an inset ring make the 2px edge, so selecting doesn't move the label. */
139
+ .plinth-section-menu-item:is([aria-pressed='true'], [aria-current='page']) {
140
+ color: var(--plinth-color-accent-text);
141
+ background-color: var(--plinth-color-accent-subtle);
142
+ border-color: var(--plinth-color-accent);
143
+ box-shadow: inset 0 0 0 calc(var(--plinth-border-width-thick) - var(--plinth-border-width-thin))
144
+ var(--plinth-color-accent);
145
+ }
146
+
147
+ /*
148
+ * A pressed button can be pressed again, so it answers hover and press with a step on the tint.
149
+ * Not in forced colours, where the tint would replace the system highlight under the label.
150
+ */
151
+ @media (hover: hover) and (forced-colors: none) {
152
+ button.plinth-section-menu-item:enabled[aria-pressed='true']:hover {
153
+ background-color: var(--plinth-color-accent-subtle-hover);
154
+ }
155
+ }
156
+
157
+ @media (forced-colors: none) {
158
+ button.plinth-section-menu-item:enabled[aria-pressed='true']:active {
159
+ background-color: var(--plinth-color-accent-subtle-active);
160
+ }
161
+ }
162
+
163
+ .plinth-section-menu-item:disabled,
164
+ .plinth-section-menu-item[aria-disabled='true'] {
165
+ cursor: not-allowed;
166
+ opacity: var(--plinth-opacity-disabled);
167
+ }
168
+
169
+ .plinth-section-menu-item:focus-visible {
170
+ outline: var(--plinth-focus-ring-width) solid var(--plinth-color-focus-ring);
171
+ outline-offset: var(--plinth-focus-ring-offset);
172
+ }
173
+
174
+ /* A gap between the ring and the accent edge, so focus and selection read apart. */
175
+ .plinth-section-menu-item:is([aria-pressed='true'], [aria-current='page']):focus-visible {
176
+ outline-offset: var(--plinth-focus-ring-offset-filled);
177
+ }
178
+
179
+ .plinth-section-menu-count {
180
+ font-family: var(--plinth-font-family-mono);
181
+ font-size: var(--plinth-font-size-xs);
182
+ font-weight: var(--plinth-font-weight-regular);
183
+ font-variant-numeric: tabular-nums;
184
+ color: var(--plinth-color-text-muted);
185
+ }
186
+
187
+ .plinth-section-menu-item:is([aria-pressed='true'], [aria-current='page'])
188
+ .plinth-section-menu-count {
189
+ color: inherit;
190
+ }
191
+
192
+ @media (min-width: 768px) {
193
+ .plinth-section-menu {
194
+ padding-inline: max(var(--plinth-space-4), env(safe-area-inset-left))
195
+ max(var(--plinth-space-4), env(safe-area-inset-right));
196
+ scroll-padding-inline: max(var(--plinth-space-4), env(safe-area-inset-left))
197
+ max(var(--plinth-space-4), env(safe-area-inset-right));
198
+ }
199
+
200
+ .plinth-section-menu:dir(rtl) {
201
+ padding-inline: max(var(--plinth-space-4), env(safe-area-inset-right))
202
+ max(var(--plinth-space-4), env(safe-area-inset-left));
203
+ scroll-padding-inline: max(var(--plinth-space-4), env(safe-area-inset-right))
204
+ max(var(--plinth-space-4), env(safe-area-inset-left));
205
+ }
206
+ }
207
+
208
+ /* The current item is only a surface tint: in forced colours it takes the system highlight. */
209
+ @media (forced-colors: active) {
210
+ .plinth-section-menu-item:is([aria-pressed='true'], [aria-current='page']) {
211
+ color: HighlightText;
212
+ background-color: Highlight;
213
+ border-color: Highlight;
214
+ }
215
+
216
+ .plinth-section-menu-item:is([aria-pressed='true'], [aria-current='page'])
217
+ .plinth-section-menu-count {
218
+ color: HighlightText;
219
+ }
220
+
221
+ .plinth-section-menu-item:disabled,
222
+ .plinth-section-menu-item[aria-disabled='true'] {
223
+ color: GrayText;
224
+ }
225
+ }
226
+ }
@@ -0,0 +1,239 @@
1
+ /*
2
+ * Sheet: a modal panel on the edge of the screen the developer picks, for filters, side
3
+ * information or navigation. Optionally on another edge below 768px.
4
+ *
5
+ * <dialog id="filters" class="plinth-sheet" data-side="end" data-side-phone="bottom"
6
+ * closedby="any" aria-labelledby="filters-title" aria-describedby="filters-description">
7
+ * <div class="plinth-sheet-panel">
8
+ * <div class="plinth-sheet-handle" aria-hidden="true"></div>
9
+ * <div class="plinth-sheet-header">
10
+ * <h2 id="filters-title" class="plinth-sheet-title">Filters</h2>
11
+ * <p id="filters-description" class="plinth-sheet-description">Narrow the list down.</p>
12
+ * <button type="button" class="plinth-button plinth-icon-button plinth-sheet-close"
13
+ * data-variant="ghost" data-size="md" aria-label="Close" commandfor="filters"
14
+ * command="close">
15
+ * <span class="plinth-icon" data-size="md" aria-hidden="true"><svg>…</svg></span>
16
+ * </button>
17
+ * </div>
18
+ * <div class="plinth-sheet-body">…</div>
19
+ * <div class="plinth-sheet-footer">…</div>
20
+ * </div>
21
+ * </dialog>
22
+ *
23
+ * The root is the <dialog>, and it needs an id: commandfor and aria-labelledby point at it, and
24
+ * the title's and description's ids are the root's id with -title and -description. The panel is
25
+ * the root's only child and holds the parts. The handle is there only where the sheet's side on a
26
+ * phone is the bottom (data-side="bottom" with no data-side-phone, or data-side-phone="bottom"):
27
+ * decorative (aria-hidden), shown below 768px, where the sheet is dragged down to close
28
+ * (modal.css). The title is
29
+ * required; the description (and its aria-describedby) and the footer are optional. The close
30
+ * button is an icon button (button.css and icon.css) and is always there: closedby="any" (tap the
31
+ * backdrop) is an enhancement Safari doesn't have yet. A project may set closedby="closerequest"
32
+ * on a sheet that must not close on a stray tap.
33
+ *
34
+ * Open it with showModal() or a <button commandfor="filters" command="show-modal">. Open is the
35
+ * native state, [open]; the browser puts the sheet in the top layer, makes the page inert, traps
36
+ * focus, closes on Escape and returns focus. The dialog fires close when it closes. For a modal
37
+ * centred on the screen, use the dialog (dialog.css).
38
+ *
39
+ * Side (data-side), at every width: end (default, also when missing or unknown) and start are
40
+ * full-height panels on the inline-end and inline-start edges, sheet.side-width wide and at
41
+ * most the screen's width less a gap, so the page shows beside them; top and bottom are the
42
+ * screen's width on the top and bottom edges, as tall as their content up to the screen's height
43
+ * less a gap that also clears the safe area at the other edge. The sides are logical: in
44
+ * right-to-left text end is the left edge and start the right.
45
+ *
46
+ * Side on a phone (data-side-phone, optional): the side below 768px, one of the same four; from
47
+ * 768px up data-side holds. data-side="end" data-side-phone="bottom" is a side panel on a tablet
48
+ * or desktop and a bottom sheet on a phone. Leave it out to keep data-side at every width. It
49
+ * takes only those four values: any other makes the phone's side end (the layers never print
50
+ * one, and leave the attribute out instead).
51
+ *
52
+ * The edge the sheet sits on has no border or radius; the edge facing the page has a hairline
53
+ * (in forced colours too), and a top or bottom sheet rounds the corners facing the page. Each
54
+ * side clears the safe areas it touches (the notch, the status bar and the home indicator, with
55
+ * viewport-fit=cover).
56
+ *
57
+ * The description is a secondary line, at the size of a list row's description.
58
+ *
59
+ * The surface, the backdrop, the scroll lock, the motion, the safe areas and the parts come from
60
+ * the modal base (modal.css), which the dialog shares; load it with this file. The body scrolls,
61
+ * the header and footer stay put, and the page under an open sheet doesn't scroll. The sheet
62
+ * slides in from its edge and back out on the motion tokens, which drop to 0ms under
63
+ * prefers-reduced-motion. This file sets only where the sheet sits and how big it is.
64
+ *
65
+ * A sheet on the bottom edge has its height capped by 100dvh, which clears the home indicator
66
+ * (env(safe-area-inset-bottom)) but not, on its own, the on-screen keyboard: iOS lays the
67
+ * keyboard over the page. A page with a form in a sheet sets
68
+ * <meta name="viewport" content="width=device-width, initial-scale=1, interactive-widget=resizes-content">
69
+ * where the browser supports it, and keeps the sheet's fields near its top.
70
+ *
71
+ * In Livewire, a re-render would morph the open dialog back to the server's markup and drop its
72
+ * open attribute: put wire:ignore.self on the sheet (or keep it outside the component that
73
+ * re-renders), and always pass an id, since a generated one changes on every render.
74
+ */
75
+
76
+ @layer plinth {
77
+ /*
78
+ * The end side, the default. The sides are written twice: as the phone's side (data-side-phone,
79
+ * or data-side without it), and from 768px up as data-side again for a sheet with
80
+ * data-side-phone. The phone's rules use :where() so the later ones win on order alone.
81
+ */
82
+ .plinth-sheet {
83
+ /* Translate isn't logical: -1 (under :dir(rtl), below) turns the slide around. */
84
+ --plinth-sheet-direction: 1;
85
+ --plinth-sheet-safe-area-inline-start: env(safe-area-inset-left);
86
+ --plinth-sheet-safe-area-inline-end: env(safe-area-inset-right);
87
+ --plinth-modal-offset: calc(100% * var(--plinth-sheet-direction)) 0;
88
+ --plinth-modal-safe-area-end: env(safe-area-inset-bottom);
89
+
90
+ inset-block: 0;
91
+ inset-inline: auto 0;
92
+ inline-size: min(100% - var(--plinth-space-12), var(--plinth-sheet-side-width));
93
+ block-size: auto;
94
+ max-block-size: none;
95
+ padding-block: env(safe-area-inset-top) 0;
96
+ padding-inline: 0 var(--plinth-sheet-safe-area-inline-end);
97
+ margin: 0;
98
+ border-width: 0;
99
+ border-inline-start-width: var(--plinth-border-width-thin);
100
+ border-radius: 0;
101
+ }
102
+
103
+ .plinth-sheet:where([data-side='start']:not([data-side-phone]), [data-side-phone='start']) {
104
+ --plinth-modal-offset: calc(-100% * var(--plinth-sheet-direction)) 0;
105
+
106
+ inset-block: 0;
107
+ inset-inline: 0 auto;
108
+ inline-size: min(100% - var(--plinth-space-12), var(--plinth-sheet-side-width));
109
+ max-block-size: none;
110
+ padding-block: env(safe-area-inset-top) 0;
111
+ padding-inline: var(--plinth-sheet-safe-area-inline-start) 0;
112
+ border-width: 0;
113
+ border-inline-end-width: var(--plinth-border-width-thin);
114
+ border-radius: 0;
115
+ }
116
+
117
+ .plinth-sheet:where([data-side='top']:not([data-side-phone]), [data-side-phone='top']) {
118
+ --plinth-modal-offset: 0 -100%;
119
+ --plinth-modal-safe-area-end: initial;
120
+
121
+ inset-block: 0 auto;
122
+ inset-inline: 0;
123
+ inline-size: 100%;
124
+ max-block-size: calc(
125
+ 100dvh - max(var(--plinth-space-12), env(safe-area-inset-bottom) + var(--plinth-space-4))
126
+ );
127
+ padding-block: env(safe-area-inset-top) 0;
128
+ padding-inline: var(--plinth-sheet-safe-area-inline-start)
129
+ var(--plinth-sheet-safe-area-inline-end);
130
+ border-width: 0;
131
+ border-block-end-width: var(--plinth-border-width-thin);
132
+ border-radius: 0;
133
+ border-end-start-radius: var(--plinth-radius-container);
134
+ border-end-end-radius: var(--plinth-radius-container);
135
+ }
136
+
137
+ .plinth-sheet:where([data-side='bottom']:not([data-side-phone]), [data-side-phone='bottom']) {
138
+ --plinth-modal-offset: 0 100%;
139
+
140
+ inset-block: auto 0;
141
+ inset-inline: 0;
142
+ inline-size: 100%;
143
+ max-block-size: calc(
144
+ 100dvh - max(var(--plinth-space-12), env(safe-area-inset-top) + var(--plinth-space-4))
145
+ );
146
+ padding-block: 0;
147
+ padding-inline: var(--plinth-sheet-safe-area-inline-start)
148
+ var(--plinth-sheet-safe-area-inline-end);
149
+ border-width: 0;
150
+ border-block-start-width: var(--plinth-border-width-thin);
151
+ border-radius: 0;
152
+ border-start-start-radius: var(--plinth-radius-container);
153
+ border-start-end-radius: var(--plinth-radius-container);
154
+ }
155
+
156
+ /*
157
+ * With its grab handle, a bottom sheet on a phone is dragged down to close (modal.css): the root
158
+ * fills the screen, see-through, and its panel is the surface, as tall as its content up to the
159
+ * screen's height less a gap.
160
+ */
161
+ .plinth-sheet:where([data-side='bottom']:not([data-side-phone]), [data-side-phone='bottom']):has(
162
+ > * > .plinth-sheet-handle
163
+ ) {
164
+ inset: 0;
165
+ max-block-size: none;
166
+ padding: 0;
167
+ border-width: 0;
168
+ border-radius: 0;
169
+ }
170
+
171
+ /* Right-to-left text turns the slide around and swaps the safe areas. */
172
+ .plinth-sheet:dir(rtl) {
173
+ --plinth-sheet-direction: -1;
174
+ --plinth-sheet-safe-area-inline-start: env(safe-area-inset-right);
175
+ --plinth-sheet-safe-area-inline-end: env(safe-area-inset-left);
176
+ }
177
+
178
+ @media (min-width: 768px) {
179
+ .plinth-sheet[data-side-phone] {
180
+ --plinth-modal-offset: calc(100% * var(--plinth-sheet-direction)) 0;
181
+ --plinth-modal-safe-area-end: env(safe-area-inset-bottom);
182
+
183
+ inset-block: 0;
184
+ inset-inline: auto 0;
185
+ inline-size: min(100% - var(--plinth-space-12), var(--plinth-sheet-side-width));
186
+ max-block-size: none;
187
+ padding-block: env(safe-area-inset-top) 0;
188
+ padding-inline: 0 var(--plinth-sheet-safe-area-inline-end);
189
+ border-width: 0;
190
+ border-inline-start-width: var(--plinth-border-width-thin);
191
+ border-radius: 0;
192
+ }
193
+
194
+ .plinth-sheet[data-side-phone][data-side='start'] {
195
+ --plinth-modal-offset: calc(-100% * var(--plinth-sheet-direction)) 0;
196
+
197
+ inset-inline: 0 auto;
198
+ padding-inline: var(--plinth-sheet-safe-area-inline-start) 0;
199
+ border-inline-start-width: 0;
200
+ border-inline-end-width: var(--plinth-border-width-thin);
201
+ }
202
+
203
+ .plinth-sheet[data-side-phone][data-side='top'] {
204
+ --plinth-modal-offset: 0 -100%;
205
+ --plinth-modal-safe-area-end: initial;
206
+
207
+ inset-block: 0 auto;
208
+ inset-inline: 0;
209
+ inline-size: 100%;
210
+ max-block-size: calc(
211
+ 100dvh - max(var(--plinth-space-12), env(safe-area-inset-bottom) + var(--plinth-space-4))
212
+ );
213
+ padding-inline: var(--plinth-sheet-safe-area-inline-start)
214
+ var(--plinth-sheet-safe-area-inline-end);
215
+ border-block-end-width: var(--plinth-border-width-thin);
216
+ border-inline-start-width: 0;
217
+ border-end-start-radius: var(--plinth-radius-container);
218
+ border-end-end-radius: var(--plinth-radius-container);
219
+ }
220
+
221
+ .plinth-sheet[data-side='bottom'] {
222
+ --plinth-modal-offset: 0 100%;
223
+
224
+ inset-block: auto 0;
225
+ inset-inline: 0;
226
+ inline-size: 100%;
227
+ max-block-size: calc(
228
+ 100dvh - max(var(--plinth-space-12), env(safe-area-inset-top) + var(--plinth-space-4))
229
+ );
230
+ padding-block: 0;
231
+ padding-inline: var(--plinth-sheet-safe-area-inline-start)
232
+ var(--plinth-sheet-safe-area-inline-end);
233
+ border-block-start-width: var(--plinth-border-width-thin);
234
+ border-inline-start-width: 0;
235
+ border-start-start-radius: var(--plinth-radius-container);
236
+ border-start-end-radius: var(--plinth-radius-container);
237
+ }
238
+ }
239
+ }
@@ -0,0 +1,230 @@
1
+ /*
2
+ * Text field: one line of text, with its label, an optional description and an error.
3
+ *
4
+ * <div class="plinth-text-field" data-size="md">
5
+ * <label class="plinth-text-field-label" for="email">Email</label>
6
+ * <div class="plinth-text-field-control">
7
+ * <span class="plinth-icon" data-size="sm" aria-hidden="true"><svg>…</svg></span>
8
+ * <input type="email" class="plinth-text-field-input" id="email"
9
+ * aria-describedby="email-description email-error" aria-invalid="true">
10
+ * <span class="plinth-text-field-trailing">…</span>
11
+ * </div>
12
+ * <p class="plinth-text-field-description" id="email-description">For receipts only.</p>
13
+ * <p class="plinth-text-field-error" id="email-error">Enter an email address.</p>
14
+ * </div>
15
+ *
16
+ * The input needs an id: the label points at it with for, and the description's and error's ids
17
+ * are the input's id with -description and -error. The icon, the trailing slot, the description
18
+ * and the error are optional; aria-describedby lists the description, then the error, and is left
19
+ * out when there is neither.
20
+ *
21
+ * Attributes go to the input, not the root (name, value, placeholder, autocomplete, required,
22
+ * wire:model, x-model, data-*, aria-*), so the app talks to the input; only a project's class is
23
+ * added to the root's.
24
+ *
25
+ * - type (on the input): text by default; email, url, tel, password, number or search. A search
26
+ * field takes a search icon and, in the trailing slot, a clear button (a small ghost icon
27
+ * button) that the app shows when there is text and wires up. With a trailing slot, the
28
+ * browser's own clear button is hidden, so there is only one.
29
+ * - the leading icon is 16px and decorative (aria-hidden); the label names the field.
30
+ * - the trailing slot holds one small control or hint, in the room the field keeps at its end
31
+ * (--plinth-text-field-trailing-size, 32px): a small icon button, or a keyboard hint as <kbd>.
32
+ * - data-label="hidden" on the root hides the label visually; screen readers still read it. Use it
33
+ * only where the field's purpose is plain without it, such as a search box with its icon.
34
+ *
35
+ * Sizes (data-size): md 32px (default, also when missing) and sm 28px. On a touch screen both are
36
+ * 40px tall. The text is 16px on a phone, so iOS doesn't zoom into the field, and from 768px
37
+ * up it is 14px (md) or 13px (sm).
38
+ *
39
+ * States:
40
+ * - error: aria-invalid="true" on the input and the error message under it; the edge turns
41
+ * danger and thick, so it isn't only a colour, and the message says what to fix.
42
+ * - disabled: `disabled` on the input; the field is faded and can't be focused.
43
+ * - read-only: `readonly` on the input; it keeps its text and focus, without the field's fill
44
+ * and with a dashed edge.
45
+ * - the focus ring on the input's :focus-visible.
46
+ */
47
+
48
+ @layer plinth {
49
+ .plinth-text-field {
50
+ --plinth-text-field-height: var(--plinth-control-md);
51
+ --plinth-text-field-text: var(--plinth-font-size-md);
52
+
53
+ display: grid;
54
+ gap: var(--plinth-space-2);
55
+ min-inline-size: 0;
56
+ font-family: var(--plinth-font-family-body);
57
+ }
58
+
59
+ .plinth-text-field[data-size='sm'] {
60
+ --plinth-text-field-height: var(--plinth-control-sm);
61
+ --plinth-text-field-text: var(--plinth-font-size-sm);
62
+ }
63
+
64
+ .plinth-text-field-label {
65
+ font-size: var(--plinth-font-size-sm);
66
+ font-weight: var(--plinth-font-weight-medium);
67
+ line-height: var(--plinth-font-leading-normal);
68
+ color: var(--plinth-color-text-muted);
69
+ overflow-wrap: anywhere;
70
+ }
71
+
72
+ /* Hidden from sight, still read by screen readers. */
73
+ .plinth-text-field[data-label='hidden'] .plinth-text-field-label {
74
+ position: absolute;
75
+ inline-size: 1px;
76
+ block-size: 1px;
77
+ overflow: hidden;
78
+ white-space: nowrap;
79
+ clip-path: inset(50%);
80
+ }
81
+
82
+ .plinth-text-field-control {
83
+ position: relative;
84
+ display: flex;
85
+ align-items: center;
86
+ min-inline-size: 0;
87
+ }
88
+
89
+ .plinth-text-field-input {
90
+ box-sizing: border-box;
91
+ flex: 1;
92
+ min-inline-size: 0;
93
+ min-block-size: var(--plinth-text-field-height);
94
+ padding-block: 0;
95
+ padding-inline: var(--plinth-space-3);
96
+ margin: 0;
97
+ font-family: inherit;
98
+ font-size: var(--plinth-font-size-field);
99
+ line-height: var(--plinth-font-leading-tight);
100
+ color: var(--plinth-color-text);
101
+ appearance: none;
102
+ background-color: var(--plinth-color-surface-sunken);
103
+ border: var(--plinth-border-width-thin) solid var(--plinth-color-border-strong);
104
+ border-radius: var(--plinth-radius-control);
105
+ transition: border-color var(--plinth-duration-fast) var(--plinth-easing-standard);
106
+ }
107
+
108
+ .plinth-text-field-input::placeholder {
109
+ color: var(--plinth-color-text-subtle);
110
+ opacity: 1;
111
+ }
112
+
113
+ @media (hover: hover) {
114
+ .plinth-text-field-input:hover:not(:disabled, :read-only, [aria-invalid='true']) {
115
+ border-color: var(--plinth-color-text-subtle);
116
+ }
117
+ }
118
+
119
+ .plinth-text-field-input:focus-visible {
120
+ outline: var(--plinth-focus-ring-width) solid var(--plinth-color-focus-ring);
121
+ outline-offset: var(--plinth-focus-ring-offset);
122
+ }
123
+
124
+ .plinth-text-field-input[aria-invalid='true'] {
125
+ border-color: var(--plinth-color-danger);
126
+ border-width: var(--plinth-border-width-thick);
127
+ }
128
+
129
+ .plinth-text-field-input:disabled {
130
+ cursor: not-allowed;
131
+ }
132
+
133
+ /* Without the fill, and with a dashed edge, so it doesn't read as a field to type in. */
134
+ .plinth-text-field-input:read-only:not(:disabled) {
135
+ background-color: transparent;
136
+ border-style: dashed;
137
+ }
138
+
139
+ .plinth-text-field-control:has(.plinth-text-field-input:disabled) {
140
+ opacity: var(--plinth-opacity-disabled);
141
+ }
142
+
143
+ /* The leading icon sits over the input's start, which keeps room for it. */
144
+ .plinth-text-field-control > .plinth-icon {
145
+ position: absolute;
146
+ inset-inline-start: var(--plinth-space-3);
147
+ color: var(--plinth-color-text-subtle);
148
+ pointer-events: none;
149
+ }
150
+
151
+ .plinth-text-field-control:has(> .plinth-icon) > .plinth-text-field-input {
152
+ padding-inline-start: calc(
153
+ var(--plinth-space-3) + var(--plinth-size-icon-sm) + var(--plinth-space-2)
154
+ );
155
+ }
156
+
157
+ .plinth-text-field-trailing {
158
+ position: absolute;
159
+ inset-inline-end: var(--plinth-space-1);
160
+ display: flex;
161
+ gap: var(--plinth-space-1);
162
+ align-items: center;
163
+ justify-content: center;
164
+ min-inline-size: var(--plinth-text-field-trailing-size);
165
+ color: var(--plinth-color-text-subtle);
166
+ }
167
+
168
+ .plinth-text-field-control:has(> .plinth-text-field-trailing) > .plinth-text-field-input {
169
+ padding-inline-end: calc(var(--plinth-space-1) + var(--plinth-text-field-trailing-size));
170
+ }
171
+
172
+ /* With its own clear button in the trailing slot, the browser's is one too many. */
173
+ .plinth-text-field-control:has(> .plinth-text-field-trailing)
174
+ > .plinth-text-field-input::-webkit-search-cancel-button {
175
+ display: none;
176
+ }
177
+
178
+ .plinth-text-field-trailing kbd {
179
+ box-sizing: border-box;
180
+ min-inline-size: var(--plinth-size-icon-md);
181
+ padding-block: 0;
182
+ padding-inline: var(--plinth-space-1);
183
+ font-family: var(--plinth-font-family-mono);
184
+ font-size: var(--plinth-font-size-xs);
185
+ line-height: var(--plinth-font-leading-normal);
186
+ color: var(--plinth-color-text-muted);
187
+ text-align: center;
188
+ border: var(--plinth-border-width-thin) solid var(--plinth-color-border-strong);
189
+ border-radius: var(--plinth-radius-control);
190
+ }
191
+
192
+ .plinth-text-field-description,
193
+ .plinth-text-field-error {
194
+ margin: 0;
195
+ font-size: var(--plinth-font-size-sm);
196
+ line-height: var(--plinth-font-leading-normal);
197
+ overflow-wrap: anywhere;
198
+ }
199
+
200
+ .plinth-text-field-description {
201
+ color: var(--plinth-color-text-muted);
202
+ }
203
+
204
+ .plinth-text-field-error {
205
+ color: var(--plinth-color-danger-text);
206
+ }
207
+
208
+ @media (pointer: coarse) {
209
+ .plinth-text-field-input {
210
+ min-block-size: var(--plinth-touch-target);
211
+ }
212
+ }
213
+
214
+ @media (min-width: 768px) {
215
+ .plinth-text-field-input {
216
+ font-size: var(--plinth-text-field-text);
217
+ }
218
+ }
219
+
220
+ @media (forced-colors: active) {
221
+ .plinth-text-field-control:has(.plinth-text-field-input:disabled) {
222
+ color: GrayText;
223
+ }
224
+
225
+ .plinth-text-field-input:disabled {
226
+ color: GrayText;
227
+ border-color: GrayText;
228
+ }
229
+ }
230
+ }