nene-lang-ui 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +66 -2
- package/package.json +35 -4
- package/src/Ui/Alert.nn +53 -0
- package/src/Ui/Avatar.nn +49 -0
- package/src/Ui/Badge.nn +50 -0
- package/src/Ui/Button.nn +97 -0
- package/src/Ui/Card.nn +48 -0
- package/src/Ui/Checkbox.nn +34 -0
- package/src/Ui/Chip.nn +53 -0
- package/src/Ui/Dialog.nn +73 -0
- package/src/Ui/Icon.nn +90 -0
- package/src/Ui/IconButton.nn +63 -0
- package/src/Ui/Progress.nn +62 -0
- package/src/Ui/Select.nn +56 -0
- package/src/Ui/Spinner.nn +39 -0
- package/src/Ui/Switch.nn +54 -0
- package/src/Ui/Tabs.nn +50 -0
- package/src/Ui/TextField.nn +80 -0
- package/src/Ui/Theme.nn +41 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 kimuracoki
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,67 @@
|
|
|
1
|
-
#
|
|
1
|
+
# nene-lang-ui
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[Nene](https://github.com/kimuracoki/nene) の公式 UI 部品集。ボタン・入力欄・ダイアログなど、アプリを作るのに必要な部品がそろっている。ライトとダークの両方に対応し、色は CSS 変数で変えられる。
|
|
4
|
+
|
|
5
|
+
`nene new` で作ったプロジェクトには、はじめから入っている。あとから入れるときは:
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npm install nene-lang-ui
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
```haskell
|
|
12
|
+
import Ui.Button (Variant(..))
|
|
13
|
+
import qualified Ui.Icon as Icon
|
|
14
|
+
import Ui.Theme (Tone(..))
|
|
15
|
+
|
|
16
|
+
<Button onClick={Save}>保存</Button>
|
|
17
|
+
<Button variant={Outlined} tone={Danger} startIcon={Just Icon.delete} onClick={Remove}>削除</Button>
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
全部の部品を使った見本は、Nene のリポジトリの [examples/Showcase.nn](https://github.com/kimuracoki/nene/blob/main/examples/Showcase.nn)。
|
|
21
|
+
|
|
22
|
+
## 部品
|
|
23
|
+
|
|
24
|
+
| モジュール | タグ | 主な属性 |
|
|
25
|
+
| --- | --- | --- |
|
|
26
|
+
| `Ui.Button` | `<Button>…</Button>` | `onClick`、`variant`(`Filled` `Outlined` `Plain`)、`tone`、`size`、`startIcon` `endIcon`、`disabled`、`fullWidth` |
|
|
27
|
+
| `Ui.IconButton` | `<IconButton />` | `icon`、`label`(読み上げとツールチップ)、`onClick`、`variant`、`tone`、`size` |
|
|
28
|
+
| `Ui.Icon` | `<Icon />` | `name`。`Icon.add` `Icon.close` `Icon.search` など 30 種類 |
|
|
29
|
+
| `Ui.TextField` | `<TextField />` | `value`、`onInput`、`label`、`placeholder`、`helper`、`error`、`kind`(`PlainText` `Email` `Password` `Number` `Search` `Tel`) |
|
|
30
|
+
| `Ui.Select` | `<Select />` | `value`、`options`(値と表示の組のリスト)、`onChange`、`label` |
|
|
31
|
+
| `Ui.Checkbox` | `<Checkbox />` | `checked`、`onCheck`、`label` |
|
|
32
|
+
| `Ui.Switch` | `<Switch />` | `checked`、`onCheck`、`label` |
|
|
33
|
+
| `Ui.Card` | `<Card>…</Card>` | `title`、`padding`(`None` 〜 `XL`) |
|
|
34
|
+
| `Ui.Chip` | `<Chip>…</Chip>` | `tone`、`selected`、`onClick`(`Just msg` で押せるチップになる) |
|
|
35
|
+
| `Ui.Dialog` | `<Dialog>…</Dialog>` | `open`、`onClose`、`title` |
|
|
36
|
+
| `Ui.Tabs` | `<Tabs />` | `labels`、`selected`(0 から)、`onSelect` |
|
|
37
|
+
| `Ui.Alert` | `<Alert>…</Alert>` | `tone`、`title` |
|
|
38
|
+
| `Ui.Progress` | `<Progress />` | `value`(`Just 0.4`。`Nothing` なら動き続ける)、`tone` |
|
|
39
|
+
| `Ui.Spinner` | `<Spinner />` | `size`、`label` |
|
|
40
|
+
| `Ui.Avatar` | `<Avatar />` | `name`(頭文字を出す)、`src`、`size` |
|
|
41
|
+
| `Ui.Badge` | `<Badge>…</Badge>` | `count`(0 なら出さない)、`max`、`tone` |
|
|
42
|
+
|
|
43
|
+
- 色の種類 `tone` は、どの部品でも `Ui.Theme` の `Primary` `Neutral` `Success` `Warning` `Danger`。
|
|
44
|
+
- 大きさ `size` は、レイアウトタグと同じ `S` `M` `L`。
|
|
45
|
+
- 開いているか・選んでいるかなどの状態は、ほかの値と同じく model に持つ。
|
|
46
|
+
|
|
47
|
+
## 色と形を変える
|
|
48
|
+
|
|
49
|
+
部品の見た目は、部品の外から `class` で上書きできない(Nene の規則)。変えられるのは CSS 変数だけで、アプリの入口のファイルの `style` ブロックに書く。
|
|
50
|
+
|
|
51
|
+
```css
|
|
52
|
+
style
|
|
53
|
+
:root {
|
|
54
|
+
--primary: #e11d48;
|
|
55
|
+
--radius: 6px;
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
| 変数 | 意味 |
|
|
60
|
+
| --- | --- |
|
|
61
|
+
| `--primary` `--neutral` `--success` `--warning` `--danger` | 色の種類ごとの色 |
|
|
62
|
+
| `--on-tone` | 色の上に載せる文字の色 |
|
|
63
|
+
| `--text` `--muted` | 文字の色、控えめな文字の色 |
|
|
64
|
+
| `--surface` `--background` `--border` | 面、背景、枠線の色 |
|
|
65
|
+
| `--radius` `--shadow` | 角の丸み、影 |
|
|
66
|
+
|
|
67
|
+
既定の値は `light-dark()` でライトとダークの両方を持つ([src/Ui/Theme.nn](src/Ui/Theme.nn))。ダークにしないときは `:root { color-scheme: light; }`。
|
package/package.json
CHANGED
|
@@ -1,6 +1,37 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nene-lang-ui",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Nene の公式 UI 部品集。ボタン・入力欄・ダイアログなど",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"nene": {
|
|
7
|
+
"source": "src"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"src",
|
|
11
|
+
"README.md"
|
|
12
|
+
],
|
|
13
|
+
"peerDependencies": {
|
|
14
|
+
"nene-lang": "^0.1.1"
|
|
15
|
+
},
|
|
16
|
+
"publishConfig": {
|
|
17
|
+
"access": "public"
|
|
18
|
+
},
|
|
19
|
+
"peerDependenciesMeta": {
|
|
20
|
+
"nene-lang": {
|
|
21
|
+
"optional": true
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "git+https://github.com/kimuracoki/nene.git",
|
|
27
|
+
"directory": "ui"
|
|
28
|
+
},
|
|
29
|
+
"homepage": "https://github.com/kimuracoki/nene/tree/main/ui#readme",
|
|
30
|
+
"license": "MIT",
|
|
31
|
+
"author": "kimuracoki",
|
|
32
|
+
"keywords": [
|
|
33
|
+
"nene",
|
|
34
|
+
"ui",
|
|
35
|
+
"components"
|
|
36
|
+
]
|
|
37
|
+
}
|
package/src/Ui/Alert.nn
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
-- 知らせる枠。色の種類に合ったアイコンが付く
|
|
2
|
+
--
|
|
3
|
+
-- <Alert tone={Success} title="保存しました">次回からこの設定で開きます。</Alert>
|
|
4
|
+
module Ui.Alert (AlertProps, alert) where
|
|
5
|
+
|
|
6
|
+
import qualified Ui.Icon as Icon
|
|
7
|
+
import Ui.Theme (Tone(..), toneVars)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
type AlertProps =
|
|
11
|
+
{ tone :: Tone = Primary
|
|
12
|
+
, title :: String = ""
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
alert :: AlertProps -> [Html msg] -> Html msg
|
|
17
|
+
alert props children =
|
|
18
|
+
<div class={css.alert} vars={colors} role="status">
|
|
19
|
+
<Row gap={S}>
|
|
20
|
+
<span class={css.mark}><Icon name={symbol props.tone} /></span>
|
|
21
|
+
<Column gap={XS}>
|
|
22
|
+
<Heading text={props.title} />
|
|
23
|
+
<div class={css.body}>{children}</div>
|
|
24
|
+
</Column>
|
|
25
|
+
</Row>
|
|
26
|
+
</div>
|
|
27
|
+
where
|
|
28
|
+
colors = toneVars props.tone
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
heading :: { text :: String } -> Html msg
|
|
32
|
+
heading { text = "" } = <></>
|
|
33
|
+
heading { text } = <strong class={css.title}>{text}</strong>
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
symbol :: Tone -> Icon.Icon
|
|
37
|
+
symbol Primary = Icon.info
|
|
38
|
+
symbol Neutral = Icon.info
|
|
39
|
+
symbol Success = Icon.success
|
|
40
|
+
symbol Warning = Icon.warning
|
|
41
|
+
symbol Danger = Icon.error
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
style
|
|
45
|
+
.alert {
|
|
46
|
+
padding: 12px 16px;
|
|
47
|
+
border: 1px solid color-mix(in srgb, var(--tone) 30%, transparent);
|
|
48
|
+
border-radius: var(--radius);
|
|
49
|
+
background: color-mix(in srgb, var(--tone) 8%, var(--surface));
|
|
50
|
+
}
|
|
51
|
+
.mark { color: var(--tone); line-height: 1.5; }
|
|
52
|
+
.title { color: var(--tone); }
|
|
53
|
+
.body { font-size: .9375rem; }
|
package/src/Ui/Avatar.nn
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
-- 人を表す丸い印。画像がなければ名前の頭文字を出す
|
|
2
|
+
--
|
|
3
|
+
-- <Avatar name="Ada Lovelace" />
|
|
4
|
+
-- <Avatar name="Ada Lovelace" src={Just user.photo} size={L} />
|
|
5
|
+
module Ui.Avatar (AvatarProps, avatar) where
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
type AvatarProps =
|
|
9
|
+
{ name :: String
|
|
10
|
+
, src :: Maybe String = Nothing
|
|
11
|
+
, size :: Size = M
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
avatar :: AvatarProps -> Html msg
|
|
16
|
+
avatar props = <Face photo={props.src} />
|
|
17
|
+
where
|
|
18
|
+
look = classes [css.avatar, sizeClass props.size]
|
|
19
|
+
face { photo = Just url } = <img class={look} src={url} alt={props.name} />
|
|
20
|
+
face { photo = Nothing } = <span class={look} title={props.name} aria-label={props.name}>{initials props.name}</span>
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
-- "Ada Lovelace" は "AL"、"木村" は "木"
|
|
24
|
+
initials :: String -> String
|
|
25
|
+
initials name = String.toUpper (String.join "" (map (String.slice 0 1) (take 2 (words name))))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
sizeClass :: Size -> Class
|
|
29
|
+
sizeClass XS = css.small
|
|
30
|
+
sizeClass S = css.small
|
|
31
|
+
sizeClass L = css.large
|
|
32
|
+
sizeClass XL = css.large
|
|
33
|
+
sizeClass _ = css.medium
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
style
|
|
37
|
+
.avatar {
|
|
38
|
+
display: inline-grid;
|
|
39
|
+
flex: none;
|
|
40
|
+
place-items: center;
|
|
41
|
+
border-radius: 50%;
|
|
42
|
+
object-fit: cover;
|
|
43
|
+
background: color-mix(in srgb, var(--primary) 18%, var(--surface));
|
|
44
|
+
color: var(--primary);
|
|
45
|
+
font-weight: 700;
|
|
46
|
+
}
|
|
47
|
+
.small { width: 28px; height: 28px; font-size: .75rem; }
|
|
48
|
+
.medium { width: 40px; height: 40px; font-size: .9375rem; }
|
|
49
|
+
.large { width: 64px; height: 64px; font-size: 1.375rem; }
|
package/src/Ui/Badge.nn
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
-- 子要素の右上に数を重ねる。count が 0 のときは出さない
|
|
2
|
+
--
|
|
3
|
+
-- <Badge count={model.unread}>
|
|
4
|
+
-- <IconButton icon={Icon.mail} label="メール" onClick={OpenMail} />
|
|
5
|
+
-- </Badge>
|
|
6
|
+
module Ui.Badge (BadgeProps, badge) where
|
|
7
|
+
|
|
8
|
+
import Ui.Theme (Tone(..), toneVars)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
type BadgeProps =
|
|
12
|
+
{ count :: Int
|
|
13
|
+
, max :: Int = 99 -- これを超えたら 99+ と出す
|
|
14
|
+
, tone :: Tone = Danger
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
badge :: BadgeProps -> [Html msg] -> Html msg
|
|
19
|
+
badge props children =
|
|
20
|
+
<span class={css.anchor}>
|
|
21
|
+
{children}
|
|
22
|
+
<Count n={props.count} />
|
|
23
|
+
</span>
|
|
24
|
+
where
|
|
25
|
+
colors = toneVars props.tone
|
|
26
|
+
count { n = 0 } = <></>
|
|
27
|
+
count { n } = <span class={css.badge} vars={colors}>{label n}</span>
|
|
28
|
+
label n
|
|
29
|
+
| n > props.max = show props.max ++ "+"
|
|
30
|
+
| otherwise = show n
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
style
|
|
34
|
+
.anchor { position: relative; display: inline-flex; }
|
|
35
|
+
.badge {
|
|
36
|
+
position: absolute;
|
|
37
|
+
top: -4px;
|
|
38
|
+
right: -4px;
|
|
39
|
+
min-width: 18px;
|
|
40
|
+
height: 18px;
|
|
41
|
+
padding: 0 5px;
|
|
42
|
+
border: 2px solid var(--surface);
|
|
43
|
+
border-radius: 999px;
|
|
44
|
+
background: var(--tone);
|
|
45
|
+
color: var(--on-tone);
|
|
46
|
+
font-size: .6875rem;
|
|
47
|
+
font-weight: 700;
|
|
48
|
+
line-height: 14px;
|
|
49
|
+
text-align: center;
|
|
50
|
+
}
|
package/src/Ui/Button.nn
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
-- ボタン
|
|
2
|
+
--
|
|
3
|
+
-- <Button onClick={Save}>保存</Button>
|
|
4
|
+
-- <Button variant={Outlined} tone={Danger} startIcon={Just Icon.delete} onClick={Remove}>削除</Button>
|
|
5
|
+
module Ui.Button (ButtonProps, Variant(..), button) where
|
|
6
|
+
|
|
7
|
+
import qualified Ui.Icon as Icon
|
|
8
|
+
import Ui.Theme (Tone(..), toneVars)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
data Variant = Filled | Outlined | Plain
|
|
12
|
+
|
|
13
|
+
type ButtonProps msg =
|
|
14
|
+
{ onClick :: msg
|
|
15
|
+
, variant :: Variant = Filled
|
|
16
|
+
, tone :: Tone = Primary
|
|
17
|
+
, size :: Size = M
|
|
18
|
+
, startIcon :: Maybe Icon.Icon = Nothing
|
|
19
|
+
, endIcon :: Maybe Icon.Icon = Nothing
|
|
20
|
+
, disabled :: Bool = False
|
|
21
|
+
, fullWidth :: Bool = False
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
button :: ButtonProps msg -> [Html msg] -> Html msg
|
|
26
|
+
button props children =
|
|
27
|
+
<button class={look} vars={colors} type="button" disabled={props.disabled} onClick={props.onClick}>
|
|
28
|
+
<Row gap={S} align={Center} justify={Center}>
|
|
29
|
+
<Slot icon={props.startIcon} />
|
|
30
|
+
{children}
|
|
31
|
+
<Slot icon={props.endIcon} />
|
|
32
|
+
</Row>
|
|
33
|
+
</button>
|
|
34
|
+
where
|
|
35
|
+
colors = toneVars props.tone
|
|
36
|
+
look = classes [css.button, variantClass props.variant, sizeClass props.size, width]
|
|
37
|
+
width
|
|
38
|
+
| props.fullWidth = css.full
|
|
39
|
+
| otherwise = classes []
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
slot :: { icon :: Maybe Icon.Icon } -> Html msg
|
|
43
|
+
slot { icon = Just i } = <Icon name={i} />
|
|
44
|
+
slot { icon = Nothing } = <></>
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
variantClass :: Variant -> Class
|
|
48
|
+
variantClass Filled = css.filled
|
|
49
|
+
variantClass Outlined = css.outlined
|
|
50
|
+
variantClass Plain = css.plain
|
|
51
|
+
|
|
52
|
+
sizeClass :: Size -> Class
|
|
53
|
+
sizeClass XS = css.small
|
|
54
|
+
sizeClass S = css.small
|
|
55
|
+
sizeClass L = css.large
|
|
56
|
+
sizeClass XL = css.large
|
|
57
|
+
sizeClass _ = css.medium
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
style
|
|
61
|
+
.button {
|
|
62
|
+
border: 1px solid transparent;
|
|
63
|
+
border-radius: var(--radius);
|
|
64
|
+
font-weight: 600;
|
|
65
|
+
line-height: 1.2;
|
|
66
|
+
white-space: nowrap;
|
|
67
|
+
transition: background-color .15s, border-color .15s, box-shadow .15s, transform .1s;
|
|
68
|
+
}
|
|
69
|
+
.button:focus-visible { outline: 3px solid color-mix(in srgb, var(--tone) 35%, transparent); outline-offset: 1px; }
|
|
70
|
+
.button:active:not(:disabled) { transform: translateY(1px); }
|
|
71
|
+
.button:disabled { opacity: .45; cursor: not-allowed; }
|
|
72
|
+
.small { padding: 6px 12px; font-size: .8125rem; }
|
|
73
|
+
.medium { padding: 9px 16px; font-size: .9375rem; }
|
|
74
|
+
.large { padding: 12px 22px; font-size: 1.0625rem; }
|
|
75
|
+
.full { width: 100%; }
|
|
76
|
+
|
|
77
|
+
.filled {
|
|
78
|
+
background: var(--tone);
|
|
79
|
+
color: var(--on-tone);
|
|
80
|
+
}
|
|
81
|
+
.filled:hover:not(:disabled) {
|
|
82
|
+
background: color-mix(in srgb, var(--tone) 88%, var(--text));
|
|
83
|
+
box-shadow: 0 4px 14px color-mix(in srgb, var(--tone) 30%, transparent);
|
|
84
|
+
}
|
|
85
|
+
.outlined {
|
|
86
|
+
border-color: color-mix(in srgb, var(--tone) 55%, transparent);
|
|
87
|
+
background: none;
|
|
88
|
+
color: var(--tone);
|
|
89
|
+
}
|
|
90
|
+
.plain {
|
|
91
|
+
background: none;
|
|
92
|
+
color: var(--tone);
|
|
93
|
+
}
|
|
94
|
+
.outlined:hover:not(:disabled),
|
|
95
|
+
.plain:hover:not(:disabled) {
|
|
96
|
+
background: color-mix(in srgb, var(--tone) 10%, transparent);
|
|
97
|
+
}
|
package/src/Ui/Card.nn
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
-- 内容をまとめる面。title を書くと見出しが付く
|
|
2
|
+
--
|
|
3
|
+
-- <Card title="プロフィール">…</Card>
|
|
4
|
+
-- <Card padding={None}>…</Card> -- 中身を端まで広げる(リストなど)
|
|
5
|
+
module Ui.Card (CardProps, card) where
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
type CardProps =
|
|
9
|
+
{ title :: String = ""
|
|
10
|
+
, padding :: Size = L
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
card :: CardProps -> [Html msg] -> Html msg
|
|
15
|
+
card props children =
|
|
16
|
+
<section class={css.card} vars={space}>
|
|
17
|
+
<Column gap={M}>
|
|
18
|
+
<Heading text={props.title} />
|
|
19
|
+
{children}
|
|
20
|
+
</Column>
|
|
21
|
+
</section>
|
|
22
|
+
where
|
|
23
|
+
space = { cardPadding = spacing props.padding }
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
heading :: { text :: String } -> Html msg
|
|
27
|
+
heading { text = "" } = <></>
|
|
28
|
+
heading { text } = <h2 class={css.title}>{text}</h2>
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
spacing :: Size -> String
|
|
32
|
+
spacing None = "0"
|
|
33
|
+
spacing XS = "var(--space-xs)"
|
|
34
|
+
spacing S = "var(--space-s)"
|
|
35
|
+
spacing M = "var(--space-m)"
|
|
36
|
+
spacing L = "var(--space-l)"
|
|
37
|
+
spacing XL = "var(--space-xl)"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
style
|
|
41
|
+
.card {
|
|
42
|
+
padding: var(--card-padding);
|
|
43
|
+
border: 1px solid var(--border);
|
|
44
|
+
border-radius: calc(var(--radius) + 4px);
|
|
45
|
+
background: var(--surface);
|
|
46
|
+
box-shadow: var(--shadow);
|
|
47
|
+
}
|
|
48
|
+
.title { font-size: 1.0625rem; font-weight: 700; }
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
-- チェックボックス
|
|
2
|
+
--
|
|
3
|
+
-- <Checkbox label="利用規約に同意する" checked={model.agreed} onCheck={Agreed} />
|
|
4
|
+
module Ui.Checkbox (CheckboxProps, checkbox) where
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
type CheckboxProps msg =
|
|
8
|
+
{ checked :: Bool
|
|
9
|
+
, onCheck :: Bool -> msg
|
|
10
|
+
, label :: String = ""
|
|
11
|
+
, disabled :: Bool = False
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
checkbox :: CheckboxProps msg -> Html msg
|
|
16
|
+
checkbox props =
|
|
17
|
+
<label class={css.option}>
|
|
18
|
+
<Row gap={S} align={Center}>
|
|
19
|
+
<input class={css.box} type="checkbox" checked={props.checked} disabled={props.disabled} onCheck={props.onCheck} />
|
|
20
|
+
<span>{props.label}</span>
|
|
21
|
+
</Row>
|
|
22
|
+
</label>
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
style
|
|
26
|
+
.option { cursor: pointer; user-select: none; }
|
|
27
|
+
.box {
|
|
28
|
+
width: 18px;
|
|
29
|
+
height: 18px;
|
|
30
|
+
margin: 0;
|
|
31
|
+
accent-color: var(--primary);
|
|
32
|
+
cursor: pointer;
|
|
33
|
+
}
|
|
34
|
+
.box:disabled, .box:disabled + span { opacity: .5; cursor: not-allowed; }
|
package/src/Ui/Chip.nn
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
-- 小さなラベル。onClick を渡すと押せるチップ(絞り込みなど)になる
|
|
2
|
+
--
|
|
3
|
+
-- <Chip tone={Success}>完了</Chip>
|
|
4
|
+
-- <Chip selected={isAll} onClick={Just (SetFilter All)}>すべて</Chip>
|
|
5
|
+
module Ui.Chip (ChipProps, chip) where
|
|
6
|
+
|
|
7
|
+
import Ui.Theme (Tone(..), toneVars)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
type ChipProps msg =
|
|
11
|
+
{ onClick :: Maybe msg = Nothing
|
|
12
|
+
, selected :: Bool = False
|
|
13
|
+
, tone :: Tone = Neutral
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
chip :: ChipProps msg -> [Html msg] -> Html msg
|
|
18
|
+
chip props children = <Body action={props.onClick}>{children}</Body>
|
|
19
|
+
where
|
|
20
|
+
-- 押せるチップはボタンに、押せないチップはただの文字にする
|
|
21
|
+
body { action = Just msg } kids = <button class={look} vars={colors} type="button" aria-pressed={pressed} onClick={msg}>{kids}</button>
|
|
22
|
+
body { action = Nothing } kids = <span class={look} vars={colors}>{kids}</span>
|
|
23
|
+
|
|
24
|
+
colors = toneVars props.tone
|
|
25
|
+
look
|
|
26
|
+
| props.selected = classes [css.chip, css.selected]
|
|
27
|
+
| otherwise = css.chip
|
|
28
|
+
pressed
|
|
29
|
+
| props.selected = "true"
|
|
30
|
+
| otherwise = "false"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
style
|
|
34
|
+
.chip {
|
|
35
|
+
display: inline-flex;
|
|
36
|
+
align-items: center;
|
|
37
|
+
gap: 4px;
|
|
38
|
+
padding: 3px 12px;
|
|
39
|
+
border: 1px solid color-mix(in srgb, var(--tone) 35%, transparent);
|
|
40
|
+
border-radius: 999px;
|
|
41
|
+
background: color-mix(in srgb, var(--tone) 10%, transparent);
|
|
42
|
+
color: var(--tone);
|
|
43
|
+
font-size: .8125rem;
|
|
44
|
+
font-weight: 600;
|
|
45
|
+
line-height: 1.5;
|
|
46
|
+
}
|
|
47
|
+
button.chip { transition: background-color .15s; }
|
|
48
|
+
button.chip:hover { background: color-mix(in srgb, var(--tone) 18%, transparent); }
|
|
49
|
+
.selected, button.selected:hover {
|
|
50
|
+
border-color: var(--tone);
|
|
51
|
+
background: var(--tone);
|
|
52
|
+
color: var(--on-tone);
|
|
53
|
+
}
|
package/src/Ui/Dialog.nn
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
-- 画面の上に重ねて出す対話の枠。開いているかどうかは model に持つ
|
|
2
|
+
--
|
|
3
|
+
-- <Dialog open={model.confirming} title="削除しますか?" onClose={Cancel}>
|
|
4
|
+
-- <p>この操作は取り消せません。</p>
|
|
5
|
+
-- <Row gap={S} justify={End}>
|
|
6
|
+
-- <Button variant={Plain} tone={Neutral} onClick={Cancel}>やめる</Button>
|
|
7
|
+
-- <Button tone={Danger} onClick={Confirm}>削除</Button>
|
|
8
|
+
-- </Row>
|
|
9
|
+
-- </Dialog>
|
|
10
|
+
--
|
|
11
|
+
-- 背景を押すか、右上のボタンで onClose が届く。Esc で閉じるには Keyboard.onKeyDown を購読する
|
|
12
|
+
module Ui.Dialog (DialogProps, dialog) where
|
|
13
|
+
|
|
14
|
+
import qualified Ui.Icon as Icon
|
|
15
|
+
import Ui.IconButton
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
type DialogProps msg =
|
|
19
|
+
{ open :: Bool
|
|
20
|
+
, onClose :: msg
|
|
21
|
+
, title :: String = ""
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
dialog :: DialogProps msg -> [Html msg] -> Html msg
|
|
26
|
+
dialog props children = <Body visible={props.open} />
|
|
27
|
+
where
|
|
28
|
+
body { visible = True } =
|
|
29
|
+
<div class={css.overlay}>
|
|
30
|
+
<div class={css.backdrop} onClick={props.onClose} />
|
|
31
|
+
<section class={css.panel} role="dialog" aria-modal="true" aria-label={props.title}>
|
|
32
|
+
<Column gap={M}>
|
|
33
|
+
<Row gap={S} align={Center}>
|
|
34
|
+
<h2 class={css.title}>{props.title}</h2>
|
|
35
|
+
<Spacer />
|
|
36
|
+
<IconButton icon={Icon.close} label="閉じる" size={S} onClick={props.onClose} />
|
|
37
|
+
</Row>
|
|
38
|
+
{children}
|
|
39
|
+
</Column>
|
|
40
|
+
</section>
|
|
41
|
+
</div>
|
|
42
|
+
body { visible = False } = <></>
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
style
|
|
46
|
+
.overlay {
|
|
47
|
+
position: fixed;
|
|
48
|
+
inset: 0;
|
|
49
|
+
z-index: 100;
|
|
50
|
+
display: grid;
|
|
51
|
+
place-items: center;
|
|
52
|
+
padding: 16px;
|
|
53
|
+
}
|
|
54
|
+
.backdrop {
|
|
55
|
+
position: absolute;
|
|
56
|
+
inset: 0;
|
|
57
|
+
background: rgb(10 10 20 / .45);
|
|
58
|
+
animation: fade .15s ease-out;
|
|
59
|
+
}
|
|
60
|
+
.panel {
|
|
61
|
+
position: relative;
|
|
62
|
+
width: min(480px, 100%);
|
|
63
|
+
max-height: calc(100vh - 32px);
|
|
64
|
+
overflow: auto;
|
|
65
|
+
padding: 20px 24px 24px;
|
|
66
|
+
border-radius: calc(var(--radius) + 6px);
|
|
67
|
+
background: var(--surface);
|
|
68
|
+
box-shadow: 0 24px 64px rgb(0 0 0 / .25);
|
|
69
|
+
animation: rise .18s ease-out;
|
|
70
|
+
}
|
|
71
|
+
.title { font-size: 1.125rem; font-weight: 700; }
|
|
72
|
+
@keyframes fade { from { opacity: 0; } }
|
|
73
|
+
@keyframes rise { from { opacity: 0; transform: translateY(8px) scale(.98); } }
|
package/src/Ui/Icon.nn
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
-- アイコン。Icon.close のような値で選ぶので、名前を打ち間違えるとコンパイルエラーになる
|
|
2
|
+
--
|
|
3
|
+
-- import qualified Ui.Icon as Icon
|
|
4
|
+
-- <Icon name={Icon.search} />
|
|
5
|
+
--
|
|
6
|
+
-- 色は文字の色(currentColor)、大きさは文字の大きさに合わせる
|
|
7
|
+
module Ui.Icon (Icon, icon, add, remove, close, check, chevronDown, chevronUp, chevronLeft, chevronRight, arrowLeft, arrowRight, search, menu, more, edit, delete, star, starFilled, heart, home, user, settings, info, success, warning, error, mail, calendar, upload, download, link) where
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
data Icon = Icon Class
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
icon :: { name :: Icon } -> Html msg
|
|
14
|
+
icon { name = Icon glyph } = <span class={look} aria-hidden="true" />
|
|
15
|
+
where
|
|
16
|
+
look = classes [css.icon, glyph]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
add = Icon css.add -- 足す
|
|
20
|
+
remove = Icon css.remove -- 減らす
|
|
21
|
+
close = Icon css.close -- 閉じる
|
|
22
|
+
check = Icon css.check -- チェック
|
|
23
|
+
chevronDown = Icon css.chevronDown -- 下向きの矢じり
|
|
24
|
+
chevronUp = Icon css.chevronUp -- 上向きの矢じり
|
|
25
|
+
chevronLeft = Icon css.chevronLeft -- 左向きの矢じり
|
|
26
|
+
chevronRight = Icon css.chevronRight -- 右向きの矢じり
|
|
27
|
+
arrowLeft = Icon css.arrowLeft -- 左矢印
|
|
28
|
+
arrowRight = Icon css.arrowRight -- 右矢印
|
|
29
|
+
search = Icon css.search -- 検索
|
|
30
|
+
menu = Icon css.menu -- メニュー
|
|
31
|
+
more = Icon css.more -- その他
|
|
32
|
+
edit = Icon css.edit -- 編集
|
|
33
|
+
delete = Icon css.delete -- 削除
|
|
34
|
+
star = Icon css.star -- 星
|
|
35
|
+
starFilled = Icon css.starFilled -- 塗りつぶした星
|
|
36
|
+
heart = Icon css.heart -- ハート
|
|
37
|
+
home = Icon css.home -- ホーム
|
|
38
|
+
user = Icon css.user -- 人
|
|
39
|
+
settings = Icon css.settings -- 設定
|
|
40
|
+
info = Icon css.info -- お知らせ
|
|
41
|
+
success = Icon css.success -- 成功
|
|
42
|
+
warning = Icon css.warning -- 注意
|
|
43
|
+
error = Icon css.error -- エラー
|
|
44
|
+
mail = Icon css.mail -- メール
|
|
45
|
+
calendar = Icon css.calendar -- カレンダー
|
|
46
|
+
upload = Icon css.upload -- アップロード
|
|
47
|
+
download = Icon css.download -- ダウンロード
|
|
48
|
+
link = Icon css.link -- リンク
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
style
|
|
52
|
+
.icon {
|
|
53
|
+
display: inline-block;
|
|
54
|
+
flex: none;
|
|
55
|
+
width: 1.25em;
|
|
56
|
+
height: 1.25em;
|
|
57
|
+
background: currentColor;
|
|
58
|
+
-webkit-mask: var(--glyph) center / contain no-repeat;
|
|
59
|
+
mask: var(--glyph) center / contain no-repeat;
|
|
60
|
+
}
|
|
61
|
+
.add { --glyph: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 5v14M5 12h14'/%3E%3C/svg%3E"); }
|
|
62
|
+
.remove { --glyph: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M5 12h14'/%3E%3C/svg%3E"); }
|
|
63
|
+
.close { --glyph: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M18 6 6 18M6 6l12 12'/%3E%3C/svg%3E"); }
|
|
64
|
+
.check { --glyph: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 6 9 17l-5-5'/%3E%3C/svg%3E"); }
|
|
65
|
+
.chevron-down { --glyph: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E"); }
|
|
66
|
+
.chevron-up { --glyph: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m18 15-6-6-6 6'/%3E%3C/svg%3E"); }
|
|
67
|
+
.chevron-left { --glyph: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m15 18-6-6 6-6'/%3E%3C/svg%3E"); }
|
|
68
|
+
.chevron-right { --glyph: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m9 18 6-6-6-6'/%3E%3C/svg%3E"); }
|
|
69
|
+
.arrow-left { --glyph: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M19 12H5M12 19l-7-7 7-7'/%3E%3C/svg%3E"); }
|
|
70
|
+
.arrow-right { --glyph: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M5 12h14M12 5l7 7-7 7'/%3E%3C/svg%3E"); }
|
|
71
|
+
.search { --glyph: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='11' cy='11' r='7'/%3E%3Cpath d='m21 21-4.3-4.3'/%3E%3C/svg%3E"); }
|
|
72
|
+
.menu { --glyph: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M4 6h16M4 12h16M4 18h16'/%3E%3C/svg%3E"); }
|
|
73
|
+
.more { --glyph: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='5' cy='12' r='1'/%3E%3Ccircle cx='12' cy='12' r='1'/%3E%3Ccircle cx='19' cy='12' r='1'/%3E%3C/svg%3E"); }
|
|
74
|
+
.edit { --glyph: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z'/%3E%3C/svg%3E"); }
|
|
75
|
+
.delete { --glyph: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3 6h18M8 6V4h8v2M19 6l-1 14H6L5 6M10 11v6M14 11v6'/%3E%3C/svg%3E"); }
|
|
76
|
+
.star { --glyph: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m12 2 3.1 6.3 6.9 1-5 4.9 1.2 6.8L12 17.8 5.8 21l1.2-6.8-5-4.9 6.9-1Z'/%3E%3C/svg%3E"); }
|
|
77
|
+
.star-filled { --glyph: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath fill='black' d='m12 2 3.1 6.3 6.9 1-5 4.9 1.2 6.8L12 17.8 5.8 21l1.2-6.8-5-4.9 6.9-1Z'/%3E%3C/svg%3E"); }
|
|
78
|
+
.heart { --glyph: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M19 14c1.5-1.5 3-3.2 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.8 0-3 .5-4.5 2-1.5-1.5-2.7-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4 3 5.5l7 7Z'/%3E%3C/svg%3E"); }
|
|
79
|
+
.home { --glyph: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3 10.5 12 3l9 7.5V20a1 1 0 0 1-1 1h-5v-6H9v6H4a1 1 0 0 1-1-1Z'/%3E%3C/svg%3E"); }
|
|
80
|
+
.user { --glyph: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='8' r='4'/%3E%3Cpath d='M4 21a8 8 0 0 1 16 0'/%3E%3C/svg%3E"); }
|
|
81
|
+
.settings { --glyph: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M4 7h16M4 17h16'/%3E%3Ccircle cx='9' cy='7' r='2.5' fill='white'/%3E%3Ccircle cx='15' cy='17' r='2.5' fill='white'/%3E%3C/svg%3E"); }
|
|
82
|
+
.info { --glyph: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'/%3E%3Cpath d='M12 16v-4M12 8h.01'/%3E%3C/svg%3E"); }
|
|
83
|
+
.success { --glyph: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'/%3E%3Cpath d='m8.5 12 2.5 2.5 4.5-5'/%3E%3C/svg%3E"); }
|
|
84
|
+
.warning { --glyph: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0ZM12 9v4M12 17h.01'/%3E%3C/svg%3E"); }
|
|
85
|
+
.error { --glyph: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'/%3E%3Cpath d='M12 8v4M12 16h.01'/%3E%3C/svg%3E"); }
|
|
86
|
+
.mail { --glyph: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='2' y='4' width='20' height='16' rx='2'/%3E%3Cpath d='m22 7-10 6L2 7'/%3E%3C/svg%3E"); }
|
|
87
|
+
.calendar { --glyph: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='3' y='4' width='18' height='18' rx='2'/%3E%3Cpath d='M16 2v4M8 2v4M3 10h18'/%3E%3C/svg%3E"); }
|
|
88
|
+
.upload { --glyph: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M17 8l-5-5-5 5M12 3v12'/%3E%3C/svg%3E"); }
|
|
89
|
+
.download { --glyph: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3'/%3E%3C/svg%3E"); }
|
|
90
|
+
.link { --glyph: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10 13a5 5 0 0 0 7.5.5l3-3a5 5 0 0 0-7-7l-1.7 1.7M14 11a5 5 0 0 0-7.5-.5l-3 3a5 5 0 0 0 7 7l1.7-1.7'/%3E%3C/svg%3E"); }
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
-- アイコンだけのボタン。label は画面には出ず、読み上げとツールチップに使う
|
|
2
|
+
--
|
|
3
|
+
-- <IconButton icon={Icon.close} label="閉じる" onClick={Close} />
|
|
4
|
+
module Ui.IconButton (IconButtonProps, iconButton) where
|
|
5
|
+
|
|
6
|
+
import Ui.Button (Variant(..))
|
|
7
|
+
import qualified Ui.Icon as Icon
|
|
8
|
+
import Ui.Theme (Tone(..), toneVars)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
type IconButtonProps msg =
|
|
12
|
+
{ icon :: Icon.Icon
|
|
13
|
+
, label :: String
|
|
14
|
+
, onClick :: msg
|
|
15
|
+
, variant :: Variant = Plain
|
|
16
|
+
, tone :: Tone = Neutral
|
|
17
|
+
, size :: Size = M
|
|
18
|
+
, disabled :: Bool = False
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
iconButton :: IconButtonProps msg -> Html msg
|
|
23
|
+
iconButton props =
|
|
24
|
+
<button class={look} vars={colors} type="button" title={props.label} aria-label={props.label} disabled={props.disabled} onClick={props.onClick}>
|
|
25
|
+
<Icon name={props.icon} />
|
|
26
|
+
</button>
|
|
27
|
+
where
|
|
28
|
+
colors = toneVars props.tone
|
|
29
|
+
look = classes [css.iconButton, variantClass props.variant, sizeClass props.size]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
variantClass :: Variant -> Class
|
|
33
|
+
variantClass Filled = css.filled
|
|
34
|
+
variantClass Outlined = css.outlined
|
|
35
|
+
variantClass Plain = css.plain
|
|
36
|
+
|
|
37
|
+
sizeClass :: Size -> Class
|
|
38
|
+
sizeClass XS = css.small
|
|
39
|
+
sizeClass S = css.small
|
|
40
|
+
sizeClass L = css.large
|
|
41
|
+
sizeClass XL = css.large
|
|
42
|
+
sizeClass _ = css.medium
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
style
|
|
46
|
+
.icon-button {
|
|
47
|
+
display: inline-grid;
|
|
48
|
+
place-items: center;
|
|
49
|
+
border: 1px solid transparent;
|
|
50
|
+
border-radius: 999px;
|
|
51
|
+
transition: background-color .15s, color .15s;
|
|
52
|
+
}
|
|
53
|
+
.icon-button:focus-visible { outline: 3px solid color-mix(in srgb, var(--tone) 35%, transparent); }
|
|
54
|
+
.icon-button:disabled { opacity: .45; cursor: not-allowed; }
|
|
55
|
+
.small { width: 30px; height: 30px; font-size: .8125rem; }
|
|
56
|
+
.medium { width: 38px; height: 38px; font-size: .9375rem; }
|
|
57
|
+
.large { width: 48px; height: 48px; font-size: 1.125rem; }
|
|
58
|
+
.filled { background: var(--tone); color: var(--on-tone); }
|
|
59
|
+
.filled:hover:not(:disabled) { background: color-mix(in srgb, var(--tone) 88%, var(--text)); }
|
|
60
|
+
.outlined { border-color: var(--border); background: none; color: var(--tone); }
|
|
61
|
+
.plain { background: none; color: var(--tone); }
|
|
62
|
+
.outlined:hover:not(:disabled),
|
|
63
|
+
.plain:hover:not(:disabled) { background: color-mix(in srgb, var(--tone) 12%, transparent); }
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
-- 進み具合のバー。value は 0 から 1。Nothing なら終わりの見えない処理として動き続ける
|
|
2
|
+
--
|
|
3
|
+
-- <Progress value={Just 0.4} />
|
|
4
|
+
module Ui.Progress (ProgressProps, progress) where
|
|
5
|
+
|
|
6
|
+
import Ui.Theme (Tone(..), toneVars)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
type ProgressProps =
|
|
10
|
+
{ value :: Maybe Float = Nothing
|
|
11
|
+
, tone :: Tone = Primary
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
progress :: ProgressProps -> Html msg
|
|
16
|
+
progress props = <Bar value={props.value} />
|
|
17
|
+
where
|
|
18
|
+
colors = toneVars props.tone
|
|
19
|
+
bar { value = Just v } =
|
|
20
|
+
<div class={css.track} vars={colors} role="progressbar" aria-valuenow={percent}>
|
|
21
|
+
<div class={css.fill} vars={filled} />
|
|
22
|
+
</div>
|
|
23
|
+
where
|
|
24
|
+
percent = show (round (clamp v * 100.0))
|
|
25
|
+
filled = { progress = percent ++ "%" }
|
|
26
|
+
bar { value = Nothing } =
|
|
27
|
+
<div class={css.track} vars={colors} role="progressbar">
|
|
28
|
+
<div class={css.moving} />
|
|
29
|
+
</div>
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
clamp :: Float -> Float
|
|
33
|
+
clamp v = max 0.0 (min 1.0 v)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
style
|
|
37
|
+
.track {
|
|
38
|
+
position: relative;
|
|
39
|
+
height: 6px;
|
|
40
|
+
overflow: hidden;
|
|
41
|
+
border-radius: 999px;
|
|
42
|
+
background: color-mix(in srgb, var(--tone) 15%, transparent);
|
|
43
|
+
}
|
|
44
|
+
.fill {
|
|
45
|
+
width: var(--progress);
|
|
46
|
+
height: 100%;
|
|
47
|
+
border-radius: inherit;
|
|
48
|
+
background: var(--tone);
|
|
49
|
+
transition: width .3s;
|
|
50
|
+
}
|
|
51
|
+
.moving {
|
|
52
|
+
position: absolute;
|
|
53
|
+
width: 40%;
|
|
54
|
+
height: 100%;
|
|
55
|
+
border-radius: inherit;
|
|
56
|
+
background: var(--tone);
|
|
57
|
+
animation: slide 1.2s ease-in-out infinite;
|
|
58
|
+
}
|
|
59
|
+
@keyframes slide {
|
|
60
|
+
from { left: -40%; }
|
|
61
|
+
to { left: 100%; }
|
|
62
|
+
}
|
package/src/Ui/Select.nn
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
-- 選択肢から 1 つを選ぶ。options は(値, 表示する文字)の組
|
|
2
|
+
--
|
|
3
|
+
-- <Select label="並び順" value={model.order} options={orders} onChange={OrderChanged} />
|
|
4
|
+
-- where
|
|
5
|
+
-- orders = [("new", "新しい順"), ("old", "古い順")]
|
|
6
|
+
module Ui.Select (SelectProps, select) where
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
type SelectProps msg =
|
|
10
|
+
{ value :: String
|
|
11
|
+
, options :: [(String, String)]
|
|
12
|
+
, onChange :: String -> msg
|
|
13
|
+
, label :: String = ""
|
|
14
|
+
, disabled :: Bool = False
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
select :: SelectProps msg -> Html msg
|
|
19
|
+
select props =
|
|
20
|
+
<label class={css.field}>
|
|
21
|
+
<Column gap={XS}>
|
|
22
|
+
<Caption text={props.label} />
|
|
23
|
+
<select class={css.box} disabled={props.disabled} onChange={props.onChange}>
|
|
24
|
+
{map choice props.options}
|
|
25
|
+
</select>
|
|
26
|
+
</Column>
|
|
27
|
+
</label>
|
|
28
|
+
where
|
|
29
|
+
choice (value, text) = <option value={value} selected={chosen}>{text}</option>
|
|
30
|
+
where
|
|
31
|
+
chosen = value == props.value
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
caption :: { text :: String } -> Html msg
|
|
35
|
+
caption { text = "" } = <></>
|
|
36
|
+
caption { text } = <span class={css.caption}>{text}</span>
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
style
|
|
40
|
+
.field { display: block; }
|
|
41
|
+
.caption { font-size: .8125rem; font-weight: 600; }
|
|
42
|
+
.box {
|
|
43
|
+
width: 100%;
|
|
44
|
+
padding: 9px 36px 9px 12px;
|
|
45
|
+
border: 1px solid var(--border);
|
|
46
|
+
border-radius: var(--radius);
|
|
47
|
+
background: var(--surface) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23888' stroke-width='2' stroke-linecap='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E") right 10px center / 16px no-repeat;
|
|
48
|
+
appearance: none;
|
|
49
|
+
cursor: pointer;
|
|
50
|
+
}
|
|
51
|
+
.box:focus {
|
|
52
|
+
outline: none;
|
|
53
|
+
border-color: var(--primary);
|
|
54
|
+
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 25%, transparent);
|
|
55
|
+
}
|
|
56
|
+
.box:disabled { opacity: .55; cursor: not-allowed; }
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
-- 読み込み中を表す回転する印
|
|
2
|
+
--
|
|
3
|
+
-- <Spinner />
|
|
4
|
+
module Ui.Spinner (SpinnerProps, spinner) where
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
type SpinnerProps =
|
|
8
|
+
{ size :: Size = M
|
|
9
|
+
, label :: String = "読み込み中"
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
spinner :: SpinnerProps -> Html msg
|
|
14
|
+
spinner props = <span class={look} role="status" aria-label={props.label} />
|
|
15
|
+
where
|
|
16
|
+
look = classes [css.spinner, sizeClass props.size]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
sizeClass :: Size -> Class
|
|
20
|
+
sizeClass XS = css.small
|
|
21
|
+
sizeClass S = css.small
|
|
22
|
+
sizeClass L = css.large
|
|
23
|
+
sizeClass XL = css.large
|
|
24
|
+
sizeClass _ = css.medium
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
style
|
|
28
|
+
.spinner {
|
|
29
|
+
display: inline-block;
|
|
30
|
+
flex: none;
|
|
31
|
+
border: 2px solid color-mix(in srgb, var(--primary) 20%, transparent);
|
|
32
|
+
border-top-color: var(--primary);
|
|
33
|
+
border-radius: 50%;
|
|
34
|
+
animation: spin .7s linear infinite;
|
|
35
|
+
}
|
|
36
|
+
.small { width: 16px; height: 16px; }
|
|
37
|
+
.medium { width: 24px; height: 24px; }
|
|
38
|
+
.large { width: 40px; height: 40px; border-width: 3px; }
|
|
39
|
+
@keyframes spin { to { transform: rotate(360deg); } }
|
package/src/Ui/Switch.nn
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
-- オンとオフを切り替えるスイッチ
|
|
2
|
+
--
|
|
3
|
+
-- <Switch label="通知を受け取る" checked={model.notify} onCheck={NotifyChanged} />
|
|
4
|
+
module Ui.Switch (SwitchProps, switch) where
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
type SwitchProps msg =
|
|
8
|
+
{ checked :: Bool
|
|
9
|
+
, onCheck :: Bool -> msg
|
|
10
|
+
, label :: String = ""
|
|
11
|
+
, disabled :: Bool = False
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
switch :: SwitchProps msg -> Html msg
|
|
16
|
+
switch props =
|
|
17
|
+
<label class={css.option}>
|
|
18
|
+
<Row gap={S} align={Center}>
|
|
19
|
+
<input class={css.track} type="checkbox" role="switch" checked={props.checked} disabled={props.disabled} onCheck={props.onCheck} />
|
|
20
|
+
<span>{props.label}</span>
|
|
21
|
+
</Row>
|
|
22
|
+
</label>
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
style
|
|
26
|
+
.option { cursor: pointer; user-select: none; }
|
|
27
|
+
.track {
|
|
28
|
+
appearance: none;
|
|
29
|
+
position: relative;
|
|
30
|
+
flex: none;
|
|
31
|
+
width: 40px;
|
|
32
|
+
height: 22px;
|
|
33
|
+
margin: 0;
|
|
34
|
+
border-radius: 999px;
|
|
35
|
+
background: color-mix(in srgb, var(--neutral) 40%, transparent);
|
|
36
|
+
cursor: pointer;
|
|
37
|
+
transition: background-color .2s;
|
|
38
|
+
}
|
|
39
|
+
.track::after {
|
|
40
|
+
content: "";
|
|
41
|
+
position: absolute;
|
|
42
|
+
top: 3px;
|
|
43
|
+
left: 3px;
|
|
44
|
+
width: 16px;
|
|
45
|
+
height: 16px;
|
|
46
|
+
border-radius: 50%;
|
|
47
|
+
background: white;
|
|
48
|
+
box-shadow: 0 1px 2px rgb(0 0 0 / .25);
|
|
49
|
+
transition: transform .2s;
|
|
50
|
+
}
|
|
51
|
+
.track:checked { background: var(--primary); }
|
|
52
|
+
.track:checked::after { transform: translateX(18px); }
|
|
53
|
+
.track:focus-visible { outline: 3px solid color-mix(in srgb, var(--primary) 35%, transparent); }
|
|
54
|
+
.track:disabled { opacity: .5; cursor: not-allowed; }
|
package/src/Ui/Tabs.nn
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
-- タブ。どれを選んでいるかは model に持つ(0 から数える)
|
|
2
|
+
--
|
|
3
|
+
-- <Tabs labels={sections} selected={model.tab} onSelect={TabChanged} />
|
|
4
|
+
-- where
|
|
5
|
+
-- sections = ["概要", "設定", "履歴"]
|
|
6
|
+
module Ui.Tabs (TabsProps, tabs) where
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
type TabsProps msg =
|
|
10
|
+
{ labels :: [String]
|
|
11
|
+
, selected :: Int
|
|
12
|
+
, onSelect :: Int -> msg
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
tabs :: TabsProps msg -> Html msg
|
|
17
|
+
tabs props =
|
|
18
|
+
<div class={css.tabs} role="tablist">
|
|
19
|
+
<Row>{List.indexedMap tab props.labels}</Row>
|
|
20
|
+
</div>
|
|
21
|
+
where
|
|
22
|
+
tab i label = <button class={look} type="button" role="tab" aria-selected={state} onClick={choose}>{label}</button>
|
|
23
|
+
where
|
|
24
|
+
active = i == props.selected
|
|
25
|
+
choose = props.onSelect i
|
|
26
|
+
look
|
|
27
|
+
| active = classes [css.tab, css.active]
|
|
28
|
+
| otherwise = css.tab
|
|
29
|
+
state
|
|
30
|
+
| active = "true"
|
|
31
|
+
| otherwise = "false"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
style
|
|
35
|
+
.tabs { border-bottom: 1px solid var(--border); }
|
|
36
|
+
.tab {
|
|
37
|
+
margin-bottom: -1px;
|
|
38
|
+
padding: 10px 16px;
|
|
39
|
+
border: none;
|
|
40
|
+
border-bottom: 2px solid transparent;
|
|
41
|
+
background: none;
|
|
42
|
+
color: var(--muted);
|
|
43
|
+
font-weight: 600;
|
|
44
|
+
transition: color .15s, border-color .15s;
|
|
45
|
+
}
|
|
46
|
+
.tab:hover { color: var(--text); }
|
|
47
|
+
.active, .active:hover {
|
|
48
|
+
border-bottom-color: var(--primary);
|
|
49
|
+
color: var(--primary);
|
|
50
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
-- 1 行の入力欄。ラベル・説明・エラーを添えられる
|
|
2
|
+
--
|
|
3
|
+
-- <TextField label="メール" kind={Email} value={model.email} onInput={EmailChanged} error={model.emailError} />
|
|
4
|
+
module Ui.TextField (TextFieldProps, InputKind(..), textField) where
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
data InputKind = PlainText | Email | Password | Number | Search | Tel
|
|
8
|
+
|
|
9
|
+
type TextFieldProps msg =
|
|
10
|
+
{ value :: String
|
|
11
|
+
, onInput :: String -> msg
|
|
12
|
+
, label :: String = ""
|
|
13
|
+
, placeholder :: String = ""
|
|
14
|
+
, helper :: String = "" -- 欄の下に出す説明
|
|
15
|
+
, error :: Maybe String = Nothing -- Just なら赤くして、説明の代わりに出す
|
|
16
|
+
, kind :: InputKind = PlainText
|
|
17
|
+
, disabled :: Bool = False
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
textField :: TextFieldProps msg -> Html msg
|
|
22
|
+
textField props =
|
|
23
|
+
<label class={css.field}>
|
|
24
|
+
<Column gap={XS}>
|
|
25
|
+
<Caption text={props.label} />
|
|
26
|
+
<input class={box} type={inputType props.kind} value={props.value} placeholder={props.placeholder} disabled={props.disabled} aria-invalid={invalid} onInput={props.onInput} />
|
|
27
|
+
<Note helper={props.helper} error={props.error} />
|
|
28
|
+
</Column>
|
|
29
|
+
</label>
|
|
30
|
+
where
|
|
31
|
+
box
|
|
32
|
+
| props.error /= Nothing = classes [css.box, css.invalid]
|
|
33
|
+
| otherwise = css.box
|
|
34
|
+
invalid
|
|
35
|
+
| props.error /= Nothing = "true"
|
|
36
|
+
| otherwise = "false"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
caption :: { text :: String } -> Html msg
|
|
40
|
+
caption { text = "" } = <></>
|
|
41
|
+
caption { text } = <span class={css.caption}>{text}</span>
|
|
42
|
+
|
|
43
|
+
note :: { helper :: String, error :: Maybe String } -> Html msg
|
|
44
|
+
note { error = Just message } = <span class={css.error}>{message}</span>
|
|
45
|
+
note { helper = "" } = <></>
|
|
46
|
+
note { helper } = <span class={css.helper}>{helper}</span>
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
inputType :: InputKind -> String
|
|
50
|
+
inputType PlainText = "text"
|
|
51
|
+
inputType Email = "email"
|
|
52
|
+
inputType Password = "password"
|
|
53
|
+
inputType Number = "number"
|
|
54
|
+
inputType Search = "search"
|
|
55
|
+
inputType Tel = "tel"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
style
|
|
59
|
+
.field { display: block; flex: 1; }
|
|
60
|
+
.caption { font-size: .8125rem; font-weight: 600; color: var(--text); }
|
|
61
|
+
.box {
|
|
62
|
+
width: 100%;
|
|
63
|
+
padding: 9px 12px;
|
|
64
|
+
border: 1px solid var(--border);
|
|
65
|
+
border-radius: var(--radius);
|
|
66
|
+
background: var(--surface);
|
|
67
|
+
transition: border-color .15s, box-shadow .15s;
|
|
68
|
+
}
|
|
69
|
+
.box:hover:not(:disabled) { border-color: color-mix(in srgb, var(--text) 30%, var(--border)); }
|
|
70
|
+
.box:focus {
|
|
71
|
+
outline: none;
|
|
72
|
+
border-color: var(--primary);
|
|
73
|
+
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 25%, transparent);
|
|
74
|
+
}
|
|
75
|
+
.box:disabled { opacity: .55; cursor: not-allowed; }
|
|
76
|
+
.invalid, .invalid:focus { border-color: var(--danger); }
|
|
77
|
+
.invalid:focus { box-shadow: 0 0 0 3px color-mix(in srgb, var(--danger) 25%, transparent); }
|
|
78
|
+
.helper, .error { font-size: .8125rem; }
|
|
79
|
+
.helper { color: var(--muted); }
|
|
80
|
+
.error { color: var(--danger); }
|
package/src/Ui/Theme.nn
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
-- 色と形の決まり(CSS 変数)と、部品に共通の色の種類(Tone)
|
|
2
|
+
--
|
|
3
|
+
-- 色を変えるときは、アプリの入口のファイルの style ブロックで :root の変数を上書きする:
|
|
4
|
+
--
|
|
5
|
+
-- style
|
|
6
|
+
-- :root { --primary: #e11d48; --radius: 6px; }
|
|
7
|
+
--
|
|
8
|
+
-- ライトとダークの両方の色を light-dark() で持つ。ダークにしないときは :root { color-scheme: light; }
|
|
9
|
+
module Ui.Theme (Tone(..), toneVars) where
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
-- 部品の色の種類。どの部品でも同じものを使う
|
|
13
|
+
data Tone = Primary | Neutral | Success | Warning | Danger
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
-- 部品の style ブロックで var(--tone) として使う
|
|
17
|
+
toneVars :: Tone -> { tone :: String }
|
|
18
|
+
toneVars Primary = { tone = "var(--primary)" }
|
|
19
|
+
toneVars Neutral = { tone = "var(--neutral)" }
|
|
20
|
+
toneVars Success = { tone = "var(--success)" }
|
|
21
|
+
toneVars Warning = { tone = "var(--warning)" }
|
|
22
|
+
toneVars Danger = { tone = "var(--danger)" }
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
style
|
|
26
|
+
:root {
|
|
27
|
+
color-scheme: light dark;
|
|
28
|
+
--primary: light-dark(#5b5bd6, #8b8bf5);
|
|
29
|
+
--neutral: light-dark(#5f6372, #a4a8b5);
|
|
30
|
+
--success: light-dark(#18864b, #3dd68c);
|
|
31
|
+
--warning: light-dark(#b45309, #f5b14c);
|
|
32
|
+
--danger: light-dark(#d03a3a, #ff6b6b);
|
|
33
|
+
--on-tone: light-dark(#ffffff, #111216);
|
|
34
|
+
--text: light-dark(#1d1e24, #ecedf2);
|
|
35
|
+
--muted: light-dark(#6b6f7d, #9a9eab);
|
|
36
|
+
--surface: light-dark(#ffffff, #1b1c22);
|
|
37
|
+
--background: light-dark(#f6f6f9, #111216);
|
|
38
|
+
--border: light-dark(#dfe0e6, #2e3039);
|
|
39
|
+
--radius: 10px;
|
|
40
|
+
--shadow: 0 1px 2px rgb(0 0 0 / .06), 0 4px 16px rgb(0 0 0 / .06);
|
|
41
|
+
}
|