@wells-nelson/displays 1.0.2 → 1.0.4

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/README.md CHANGED
@@ -20,10 +20,15 @@ Tokens: `--bg`, `--surface`, `--surface-2`, `--surface-hover`, `--text`, `--mute
20
20
  `.stack`, `.card`, `.row-card`, `.muted`, `.notice`, `.empty`, `.visually-hidden`, plus
21
21
  drag-and-drop classes. Keep app-only tokens in the app's own stylesheet.
22
22
 
23
+ Date and time inputs (`date`, `time`, `datetime-local`) match the text inputs, and on iOS keep a
24
+ fixed size whatever their value. On touch screens text can't be selected (a long press acts like an
25
+ app, not a page), except in fields and anything marked `.selectable`; `Leaderboards` and
26
+ `SortableTable` are.
27
+
23
28
  ## Icons
24
29
 
25
- The base set: alert, check, chevron, cloud, cloud-pending, gear, grip, home, mail, pencil, plus,
26
- refresh, search, share, trash, undo, user, wifi-off, x. Register app icons at startup:
30
+ The base set: alert, check, chevron, cloud, cloud-pending, download, gear, grip, home, mail, pencil, plus,
31
+ refresh, search, share, trash, undo, upload, user, wifi-off, x. Register app icons at startup:
27
32
 
28
33
  ```ts
29
34
  import { registerIcons } from '@wells-nelson/displays';
@@ -42,12 +47,13 @@ registerIcons({ calendar: '<rect …/>', folder: '<path …/>' }); // 24x24, str
42
47
  unsynced={cloud.hasAnyPending || cloud.sessionExpired}
43
48
  hideOnScroll
44
49
  width={isDashboard ? '64rem' : undefined}
50
+ labels="always"
45
51
  >
46
52
  {#snippet brand()}MA{/snippet}
47
- {#snippet nav()}<IconButton icon="calendar" label="Schedule" href={…} active={…} />{/snippet}
53
+ {#snippet nav()}<IconButton icon="calendar" label="Schedule" href={…} active={…} showLabel />{/snippet}
48
54
  </AppHeader>
49
55
 
50
- <!-- Phones: a bottom tab bar instead of the header (set --header-h: 0px at that width). -->
56
+ <!-- Phones: a bottom tab bar instead of the header. -->
51
57
  <TabBar
52
58
  items={[
53
59
  { label: 'Home', icon: 'home', href: resolve('/'), active: routeId === '/' },
@@ -75,6 +81,24 @@ registerIcons({ calendar: '<rect …/>', folder: '<path …/>' }); // 24x24, str
75
81
  <FatalError error={app.fatal} />
76
82
  ```
77
83
 
84
+ Nav buttons with `showLabel` show their label to the right of the icon. `AppHeader`'s `labels` decides
85
+ which: `always` (default) all of them, `active` only the current one. `profileShowLabel` does the same
86
+ for the avatar (off by default).
87
+
88
+ **Phones.** Show a `TabBar` instead of the `AppHeader` when `phoneLayout.current` is true: narrow
89
+ screens (up to 40rem) and touch screens held sideways (up to 30rem tall). It goes by the screen,
90
+ not the user agent, which can't tell an iPad from a Mac; the query is `PHONE_LAYOUT_QUERY`. While
91
+ mounted, `TabBar` sets `--header-h: 0px` so pages start at the top, and `--footer-offset` so fixed
92
+ bottom UI sits above it.
93
+
94
+ ```svelte
95
+ <script>
96
+ import { phoneLayout } from '@wells-nelson/displays';
97
+ </script>
98
+
99
+ {#if phoneLayout.current}<TabBar items={…} />{:else}<AppHeader …>…</AppHeader>{/if}
100
+ ```
101
+
78
102
  `Button` (primary / secondary / danger / ghost, optional icon, `block`), `IconButton` (button or
79
- link, required label, `active`), `Modal` (native `<dialog>`, sm / md / lg) and `ConfirmDialog`
103
+ link, required label, `active`, `showLabel`), `Modal` (native `<dialog>`, sm / md / lg) and `ConfirmDialog`
80
104
  (danger variant, busy state) complete the set. See them all in the sandbox (`npm run sandbox`).
@@ -19,14 +19,19 @@
19
19
  unsynced = false,
20
20
  unsyncedLabel = 'Unsynced changes',
21
21
  hideOnScroll = false,
22
- width = 'var(--content-w)'
22
+ width = 'var(--content-w)',
23
+ labels = 'always',
24
+ profileShowLabel = false
23
25
  }: {
24
26
  /** Brand mark, rendered inside the home link. */
25
27
  brand: Snippet;
26
28
  brandHref: string;
27
29
  /** Accessible name of the home link, e.g. "Home — all projects". */
28
30
  brandLabel: string;
29
- /** Context navigation, usually `IconButton`s with `href`. */
31
+ /**
32
+ * Context navigation, usually `IconButton`s with `href`. Give them `showLabel` to show their
33
+ * label next to the icon (see `labels`).
34
+ */
30
35
  nav?: Snippet;
31
36
  /** Development-only tools; the app passes this only in dev builds. */
32
37
  dev?: Snippet;
@@ -41,6 +46,13 @@
41
46
  hideOnScroll?: boolean;
42
47
  /** Max width of the header content (CSS length), e.g. a wider value on dashboard screens. */
43
48
  width?: string;
49
+ /**
50
+ * Which nav buttons with `showLabel` show their label: `always` all of them, `active` only the
51
+ * current one (the others show just the icon).
52
+ */
53
+ labels?: 'always' | 'active';
54
+ /** Show `profileLabel` next to the avatar, like a nav button with `showLabel`. */
55
+ profileShowLabel?: boolean;
44
56
  } = $props();
45
57
 
46
58
  let scrolledAway = $state(false);
@@ -68,18 +80,28 @@
68
80
  <header class="app-header" class:hidden>
69
81
  <div class="inner" style:max-width={width}>
70
82
  <a class="brand" href={brandHref} aria-label={brandLabel}>{@render brand()}</a>
71
- <nav aria-label="Main">
83
+ <nav aria-label="Main" class="labels-{labels}">
72
84
  {@render dev?.()}
73
85
  {@render nav?.()}
74
- <a class="profile" href={profileHref} aria-label={profileLabel} title={profileLabel} class:active={profileActive}>
75
- {#if avatarUrl}
76
- <img src={avatarUrl} alt="" referrerpolicy="no-referrer" />
77
- {:else}
78
- <Icon name="user" size={22} />
79
- {/if}
80
- {#if unsynced}
81
- <span class="dot" title={unsyncedLabel}></span>
82
- {/if}
86
+ <a
87
+ class="profile"
88
+ class:labeled={profileShowLabel}
89
+ href={profileHref}
90
+ aria-label={profileLabel}
91
+ title={profileShowLabel ? undefined : profileLabel}
92
+ class:active={profileActive}
93
+ >
94
+ <span class="avatar">
95
+ {#if avatarUrl}
96
+ <img src={avatarUrl} alt="" referrerpolicy="no-referrer" />
97
+ {:else}
98
+ <Icon name="user" size={22} />
99
+ {/if}
100
+ {#if unsynced}
101
+ <span class="dot" title={unsyncedLabel}></span>
102
+ {/if}
103
+ </span>
104
+ {#if profileShowLabel}<span class="text" aria-hidden="true">{profileLabel}</span>{/if}
83
105
  </a>
84
106
  </nav>
85
107
  </div>
@@ -118,15 +140,47 @@
118
140
  nav {
119
141
  display: flex;
120
142
  align-items: center;
143
+ gap: 0.1rem;
144
+ }
145
+ /* labels="active": labeled buttons other than the current one show just their icon. */
146
+ .labels-active :global(.icon-btn.labeled:not(.active)) {
147
+ width: 2.5rem;
148
+ padding: 0;
149
+ justify-content: center;
150
+ border-radius: 50%;
151
+ }
152
+ .labels-active :global(.icon-btn.labeled:not(.active) .text),
153
+ .labels-active .profile.labeled:not(.active) .text {
154
+ display: none;
121
155
  }
122
156
  .profile {
157
+ display: inline-flex;
158
+ align-items: center;
159
+ justify-content: center;
160
+ gap: 0.4rem;
161
+ min-width: 2.5rem;
162
+ height: 2.5rem;
163
+ border-radius: 1.25rem;
164
+ color: inherit;
165
+ text-decoration: none;
166
+ }
167
+ .profile.labeled {
168
+ padding: 0 0.85rem 0 0.3rem;
169
+ }
170
+ .labels-active .profile.labeled:not(.active) {
171
+ padding: 0;
172
+ }
173
+ .avatar {
123
174
  position: relative;
124
175
  display: grid;
125
176
  place-items: center;
126
177
  width: 2.5rem;
127
178
  height: 2.5rem;
128
- border-radius: 50%;
129
- color: inherit;
179
+ }
180
+ .text {
181
+ font-size: 0.92rem;
182
+ font-weight: 600;
183
+ white-space: nowrap;
130
184
  }
131
185
  .profile:hover,
132
186
  .profile.active {
@@ -5,7 +5,10 @@ type $$ComponentProps = {
5
5
  brandHref: string;
6
6
  /** Accessible name of the home link, e.g. "Home — all projects". */
7
7
  brandLabel: string;
8
- /** Context navigation, usually `IconButton`s with `href`. */
8
+ /**
9
+ * Context navigation, usually `IconButton`s with `href`. Give them `showLabel` to show their
10
+ * label next to the icon (see `labels`).
11
+ */
9
12
  nav?: Snippet;
10
13
  /** Development-only tools; the app passes this only in dev builds. */
11
14
  dev?: Snippet;
@@ -20,6 +23,13 @@ type $$ComponentProps = {
20
23
  hideOnScroll?: boolean;
21
24
  /** Max width of the header content (CSS length), e.g. a wider value on dashboard screens. */
22
25
  width?: string;
26
+ /**
27
+ * Which nav buttons with `showLabel` show their label: `always` all of them, `active` only the
28
+ * current one (the others show just the icon).
29
+ */
30
+ labels?: 'always' | 'active';
31
+ /** Show `profileLabel` next to the avatar, like a nav button with `showLabel`. */
32
+ profileShowLabel?: boolean;
23
33
  };
24
34
  declare const AppHeader: import("svelte").Component<$$ComponentProps, {}, "">;
25
35
  type AppHeader = ReturnType<typeof AppHeader>;
@@ -9,7 +9,8 @@
9
9
  onclick,
10
10
  active = false,
11
11
  size = 22,
12
- disabled = false
12
+ disabled = false,
13
+ showLabel = false
13
14
  }: {
14
15
  icon: IconName;
15
16
  label: string;
@@ -18,16 +19,43 @@
18
19
  active?: boolean;
19
20
  size?: number;
20
21
  disabled?: boolean;
22
+ /**
23
+ * Show the label to the right of the icon. In `AppHeader`'s nav, its `labels` option can
24
+ * limit this to the active button.
25
+ */
26
+ showLabel?: boolean;
21
27
  } = $props();
22
28
  </script>
23
29
 
30
+ {#snippet content()}
31
+ <Icon name={icon} {size} />
32
+ {#if showLabel}<span class="text" aria-hidden="true">{label}</span>{/if}
33
+ {/snippet}
34
+
24
35
  {#if href}
25
- <a class="icon-btn" class:active {href} aria-label={label} title={label} aria-current={active ? 'page' : undefined}>
26
- <Icon name={icon} {size} />
36
+ <a
37
+ class="icon-btn"
38
+ class:active
39
+ class:labeled={showLabel}
40
+ {href}
41
+ aria-label={label}
42
+ title={showLabel ? undefined : label}
43
+ aria-current={active ? 'page' : undefined}
44
+ >
45
+ {@render content()}
27
46
  </a>
28
47
  {:else}
29
- <button type="button" class="icon-btn" class:active aria-label={label} title={label} {onclick} {disabled}>
30
- <Icon name={icon} {size} />
48
+ <button
49
+ type="button"
50
+ class="icon-btn"
51
+ class:active
52
+ class:labeled={showLabel}
53
+ aria-label={label}
54
+ title={showLabel ? undefined : label}
55
+ {onclick}
56
+ {disabled}
57
+ >
58
+ {@render content()}
31
59
  </button>
32
60
  {/if}
33
61
 
@@ -45,6 +73,19 @@
45
73
  cursor: pointer;
46
74
  text-decoration: none;
47
75
  }
76
+ .labeled {
77
+ display: inline-flex;
78
+ align-items: center;
79
+ gap: 0.4rem;
80
+ width: auto;
81
+ padding: 0 0.85rem 0 0.6rem;
82
+ border-radius: 1.25rem;
83
+ }
84
+ .text {
85
+ font-size: 0.92rem;
86
+ font-weight: 600;
87
+ white-space: nowrap;
88
+ }
48
89
  .icon-btn:hover:not(:disabled) {
49
90
  background: var(--surface-hover);
50
91
  }
@@ -7,6 +7,11 @@ type $$ComponentProps = {
7
7
  active?: boolean;
8
8
  size?: number;
9
9
  disabled?: boolean;
10
+ /**
11
+ * Show the label to the right of the icon. In `AppHeader`'s nav, its `labels` option can
12
+ * limit this to the active button.
13
+ */
14
+ showLabel?: boolean;
10
15
  };
11
16
  declare const IconButton: import("svelte").Component<$$ComponentProps, {}, "">;
12
17
  type IconButton = ReturnType<typeof IconButton>;
@@ -16,7 +16,7 @@
16
16
  } = $props();
17
17
  </script>
18
18
 
19
- <div class="boards" role="region" aria-label={label}>
19
+ <div class="boards selectable" role="region" aria-label={label}>
20
20
  {#each boards as board (board.title)}
21
21
  <section class="board card">
22
22
  <h2>{board.title}</h2>
@@ -47,7 +47,7 @@
47
47
  }
48
48
  </script>
49
49
 
50
- <section class="table-card card">
50
+ <section class="table-card card selectable">
51
51
  {#if title}<h2>{title}</h2>{/if}
52
52
  {#if rows.length === 0}
53
53
  <p class="muted empty-row">{emptyText}</p>
@@ -4,17 +4,21 @@
4
4
 
5
5
  /**
6
6
  * Fixed bottom navigation for phones: one tab per top-level destination, icon over label.
7
- * Use it instead of `AppHeader` on small screens (set `--header-h: 0px` there so the page
8
- * starts at the top). While mounted it sets `--footer-offset` on <html>, which `SearchBar`,
9
- * `UndoButton` and other fixed bottom UI use to sit above it, and it leaves a spacer of its
10
- * height at the end of the page.
7
+ * Use it instead of `AppHeader` on phones (`phoneLayout` says when). While mounted it sets
8
+ * `--header-h: 0px` on <html>, so pages start at the top, and `--footer-offset`, which
9
+ * `SearchBar`, `UndoButton` and other fixed bottom UI use to sit above it; it leaves a spacer
10
+ * of its height at the end of the page.
11
11
  */
12
12
  let { items, label = 'Main' }: { items: TabItem[]; label?: string } = $props();
13
13
 
14
14
  $effect(() => {
15
15
  const root = document.documentElement.style;
16
16
  root.setProperty('--footer-offset', 'calc(var(--tabbar-h) + env(safe-area-inset-bottom, 0px))');
17
- return () => root.removeProperty('--footer-offset');
17
+ root.setProperty('--header-h', '0px');
18
+ return () => {
19
+ root.removeProperty('--footer-offset');
20
+ root.removeProperty('--header-h');
21
+ };
18
22
  });
19
23
  </script>
20
24
 
package/dist/icons.d.ts CHANGED
@@ -9,6 +9,7 @@ export declare const baseIcons: {
9
9
  readonly chevron: "<path d=\"M6 9l6 6 6-6\"/>";
10
10
  readonly cloud: "<path d=\"M7 18.5h10.5a4 4 0 0 0 .6-8 6 6 0 0 0-11.6 1.6A3.3 3.3 0 0 0 7 18.5z\"/>";
11
11
  readonly 'cloud-pending': "<path d=\"M7 18.5h10.5a4 4 0 0 0 .6-8 6 6 0 0 0-11.6 1.6A3.3 3.3 0 0 0 7 18.5z\"/><path d=\"M12 10.5v5M9.8 12.7l2.2-2.2 2.2 2.2\"/>";
12
+ readonly download: "<path d=\"M12 4v11M7.5 10.5L12 15l4.5-4.5\"/><path d=\"M4.5 16v3a1.5 1.5 0 0 0 1.5 1.5h12a1.5 1.5 0 0 0 1.5-1.5v-3\"/>";
12
13
  readonly gear: "<circle cx=\"12\" cy=\"12\" r=\"3\"/><path d=\"M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z\"/>";
13
14
  readonly grip: "<circle cx=\"9\" cy=\"6\" r=\"1\"/><circle cx=\"15\" cy=\"6\" r=\"1\"/><circle cx=\"9\" cy=\"12\" r=\"1\"/><circle cx=\"15\" cy=\"12\" r=\"1\"/><circle cx=\"9\" cy=\"18\" r=\"1\"/><circle cx=\"15\" cy=\"18\" r=\"1\"/>";
14
15
  readonly home: "<path d=\"M3.5 11L12 4l8.5 7\"/><path d=\"M6 9.5V20h4.5v-5.5h3V20H18V9.5\"/>";
@@ -19,6 +20,7 @@ export declare const baseIcons: {
19
20
  readonly search: "<circle cx=\"11\" cy=\"11\" r=\"6.5\"/><path d=\"M20 20l-4.4-4.4\"/>";
20
21
  readonly share: "<circle cx=\"18\" cy=\"5.5\" r=\"2.5\"/><circle cx=\"6\" cy=\"12\" r=\"2.5\"/><circle cx=\"18\" cy=\"18.5\" r=\"2.5\"/><path d=\"M8.2 10.8l7.6-4.1M8.2 13.2l7.6 4.1\"/>";
21
22
  readonly trash: "<path d=\"M4 7h16M10 11v6M14 11v6M5.5 7l1 12.5a1.5 1.5 0 0 0 1.5 1.5h8a1.5 1.5 0 0 0 1.5-1.5l1-12.5M9 7V4.5A1.5 1.5 0 0 1 10.5 3h3A1.5 1.5 0 0 1 15 4.5V7\"/>";
23
+ readonly upload: "<path d=\"M12 15V4M7.5 8.5L12 4l4.5 4.5\"/><path d=\"M4.5 16v3a1.5 1.5 0 0 0 1.5 1.5h12a1.5 1.5 0 0 0 1.5-1.5v-3\"/>";
22
24
  readonly undo: "<path d=\"M9 14L4 9l5-5\"/><path d=\"M4 9h10.5a5.5 5.5 0 0 1 0 11H11\"/>";
23
25
  readonly user: "<circle cx=\"12\" cy=\"8\" r=\"4\"/><path d=\"M4 21a8 8 0 0 1 16 0\"/>";
24
26
  readonly 'wifi-off': "<path d=\"M3 3l18 18M8.5 16.5a5 5 0 0 1 7 0M5 12.9a10 10 0 0 1 4.2-2.4M14.8 10.5A10 10 0 0 1 19 12.9M2 9a15 15 0 0 1 4.1-2.6M10.7 5.1A15 15 0 0 1 22 9\"/><circle cx=\"12\" cy=\"20\" r=\"0.6\"/>";
package/dist/icons.js CHANGED
@@ -9,6 +9,7 @@ export const baseIcons = {
9
9
  chevron: '<path d="M6 9l6 6 6-6"/>',
10
10
  cloud: '<path d="M7 18.5h10.5a4 4 0 0 0 .6-8 6 6 0 0 0-11.6 1.6A3.3 3.3 0 0 0 7 18.5z"/>',
11
11
  'cloud-pending': '<path d="M7 18.5h10.5a4 4 0 0 0 .6-8 6 6 0 0 0-11.6 1.6A3.3 3.3 0 0 0 7 18.5z"/><path d="M12 10.5v5M9.8 12.7l2.2-2.2 2.2 2.2"/>',
12
+ download: '<path d="M12 4v11M7.5 10.5L12 15l4.5-4.5"/><path d="M4.5 16v3a1.5 1.5 0 0 0 1.5 1.5h12a1.5 1.5 0 0 0 1.5-1.5v-3"/>',
12
13
  gear: '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"/>',
13
14
  grip: '<circle cx="9" cy="6" r="1"/><circle cx="15" cy="6" r="1"/><circle cx="9" cy="12" r="1"/><circle cx="15" cy="12" r="1"/><circle cx="9" cy="18" r="1"/><circle cx="15" cy="18" r="1"/>',
14
15
  home: '<path d="M3.5 11L12 4l8.5 7"/><path d="M6 9.5V20h4.5v-5.5h3V20H18V9.5"/>',
@@ -19,6 +20,7 @@ export const baseIcons = {
19
20
  search: '<circle cx="11" cy="11" r="6.5"/><path d="M20 20l-4.4-4.4"/>',
20
21
  share: '<circle cx="18" cy="5.5" r="2.5"/><circle cx="6" cy="12" r="2.5"/><circle cx="18" cy="18.5" r="2.5"/><path d="M8.2 10.8l7.6-4.1M8.2 13.2l7.6 4.1"/>',
21
22
  trash: '<path d="M4 7h16M10 11v6M14 11v6M5.5 7l1 12.5a1.5 1.5 0 0 0 1.5 1.5h8a1.5 1.5 0 0 0 1.5-1.5l1-12.5M9 7V4.5A1.5 1.5 0 0 1 10.5 3h3A1.5 1.5 0 0 1 15 4.5V7"/>',
23
+ upload: '<path d="M12 15V4M7.5 8.5L12 4l4.5 4.5"/><path d="M4.5 16v3a1.5 1.5 0 0 0 1.5 1.5h12a1.5 1.5 0 0 0 1.5-1.5v-3"/>',
22
24
  undo: '<path d="M9 14L4 9l5-5"/><path d="M4 9h10.5a5.5 5.5 0 0 1 0 11H11"/>',
23
25
  user: '<circle cx="12" cy="8" r="4"/><path d="M4 21a8 8 0 0 1 16 0"/>',
24
26
  'wifi-off': '<path d="M3 3l18 18M8.5 16.5a5 5 0 0 1 7 0M5 12.9a10 10 0 0 1 4.2-2.4M14.8 10.5A10 10 0 0 1 19 12.9M2 9a15 15 0 0 1 4.1-2.6M10.7 5.1A15 15 0 0 1 22 9"/><circle cx="12" cy="20" r="0.6"/>',
package/dist/index.d.ts CHANGED
@@ -10,5 +10,6 @@ export { default as Modal } from './Modal.svelte';
10
10
  export { default as SortableTable } from './SortableTable.svelte';
11
11
  export { default as Splash } from './Splash.svelte';
12
12
  export { default as TabBar } from './TabBar.svelte';
13
+ export { PHONE_LAYOUT_QUERY, phoneLayout } from './layout.js';
13
14
  export { baseIcons, hasIcon, iconMarkup, registerIcons, type BaseIconName, type IconName } from './icons.js';
14
15
  export { sortRows, type Leaderboard, type SortableColumn, type TabItem } from './types.js';
package/dist/index.js CHANGED
@@ -10,5 +10,6 @@ export { default as Modal } from './Modal.svelte';
10
10
  export { default as SortableTable } from './SortableTable.svelte';
11
11
  export { default as Splash } from './Splash.svelte';
12
12
  export { default as TabBar } from './TabBar.svelte';
13
+ export { PHONE_LAYOUT_QUERY, phoneLayout } from './layout.js';
13
14
  export { baseIcons, hasIcon, iconMarkup, registerIcons } from './icons.js';
14
15
  export { sortRows } from './types.js';
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Screens that get the phone layout (a bottom `TabBar` instead of the `AppHeader`): narrow ones,
3
+ * and touch screens that are short, i.e. a phone held sideways. Tablets and desktops keep the
4
+ * header. Device type comes from how the screen is used (pointer, size), not the user agent, which
5
+ * can't tell an iPad from a Mac.
6
+ */
7
+ export declare const PHONE_LAYOUT_QUERY = "(max-width: 40rem), (pointer: coarse) and (max-height: 30rem)";
8
+ /**
9
+ * Reactive `PHONE_LAYOUT_QUERY`: read `phoneLayout.current` in a component or effect. False where
10
+ * there's no `matchMedia` (server rendering, tests).
11
+ */
12
+ export declare const phoneLayout: {
13
+ readonly current: boolean;
14
+ };
package/dist/layout.js ADDED
@@ -0,0 +1,20 @@
1
+ import { MediaQuery } from 'svelte/reactivity';
2
+ /**
3
+ * Screens that get the phone layout (a bottom `TabBar` instead of the `AppHeader`): narrow ones,
4
+ * and touch screens that are short, i.e. a phone held sideways. Tablets and desktops keep the
5
+ * header. Device type comes from how the screen is used (pointer, size), not the user agent, which
6
+ * can't tell an iPad from a Mac.
7
+ */
8
+ export const PHONE_LAYOUT_QUERY = '(max-width: 40rem), (pointer: coarse) and (max-height: 30rem)';
9
+ let query;
10
+ /**
11
+ * Reactive `PHONE_LAYOUT_QUERY`: read `phoneLayout.current` in a component or effect. False where
12
+ * there's no `matchMedia` (server rendering, tests).
13
+ */
14
+ export const phoneLayout = {
15
+ get current() {
16
+ if (typeof window === 'undefined' || typeof window.matchMedia !== 'function')
17
+ return false;
18
+ return (query ??= new MediaQuery(PHONE_LAYOUT_QUERY)).current;
19
+ }
20
+ };
package/dist/theme.css CHANGED
@@ -109,6 +109,38 @@ input:not([type]) {
109
109
  background: var(--surface);
110
110
  }
111
111
 
112
+ /*
113
+ * Date and time inputs match the text inputs. iOS draws its own control that ignores the padding
114
+ * and changes size with the value, so there it's reset to a plain box of the same height.
115
+ */
116
+ input[type='date'],
117
+ input[type='time'],
118
+ input[type='datetime-local'] {
119
+ font: inherit;
120
+ color: inherit;
121
+ width: 100%;
122
+ min-height: calc(1.4em + 1.2rem + 2px);
123
+ padding: 0.6rem 0.75rem;
124
+ border: 1px solid var(--border-strong);
125
+ border-radius: var(--radius);
126
+ background: var(--surface);
127
+ }
128
+
129
+ @supports (-webkit-touch-callout: none) {
130
+ input[type='date'],
131
+ input[type='time'],
132
+ input[type='datetime-local'] {
133
+ -webkit-appearance: none;
134
+ appearance: none;
135
+ display: block;
136
+ }
137
+ input::-webkit-date-and-time-value {
138
+ margin: 0;
139
+ text-align: left;
140
+ min-height: 1.4em;
141
+ }
142
+ }
143
+
112
144
  input:focus-visible {
113
145
  outline: none;
114
146
  border-color: var(--accent);
@@ -124,6 +156,27 @@ button {
124
156
  color: inherit;
125
157
  }
126
158
 
159
+ /*
160
+ * Touch screens: text can't be selected by accident (a long press selects instead of acting like
161
+ * an app). Fields, and anything marked `.selectable` (reports, tables, logs), still can.
162
+ * `Leaderboards` and `SortableTable` are selectable.
163
+ */
164
+ @media (hover: none) and (pointer: coarse) {
165
+ body {
166
+ -webkit-user-select: none;
167
+ user-select: none;
168
+ -webkit-touch-callout: none;
169
+ }
170
+ input,
171
+ textarea,
172
+ [contenteditable],
173
+ .selectable {
174
+ -webkit-user-select: text;
175
+ user-select: text;
176
+ -webkit-touch-callout: default;
177
+ }
178
+ }
179
+
127
180
  .visually-hidden {
128
181
  position: absolute !important;
129
182
  width: 1px;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wells-nelson/displays",
3
- "version": "1.0.2",
3
+ "version": "1.0.4",
4
4
  "description": "Svelte 5 display components and theme CSS shared by Wells Nelson apps.",
5
5
  "license": "MIT",
6
6
  "repository": {