nene-lang-ui 0.0.0-stage → 0.1.2
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 +71 -2
- package/package.json +35 -4
- package/src/Ui/Accordion.nn +43 -0
- 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/Menu.nn +122 -0
- package/src/Ui/Popover.nn +72 -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/src/Ui/Tooltip.nn +45 -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,72 @@
|
|
|
1
|
-
#
|
|
1
|
+
# nene-lang-ui
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[Nene](https://github.com/kimuracoki/nene) の公式 UI 部品集。ボタン・入力欄・ダイアログ・メニューなど 20 の部品がそろっている。ライトとダークの両方に対応し、色は 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
|
+
| `Ui.Menu` | `<Menu />` | `id`、`label`、`items`(`Item { label, onClick, icon, tone }` のリスト)、`variant` |
|
|
43
|
+
| `Ui.Popover` | `<Popover>…</Popover>` | `id`、`label`、`variant`。中身は自由 |
|
|
44
|
+
| `Ui.Accordion` | `<Accordion>…</Accordion>` | `title`、`open`(最初から開くか)、`group`(同じ group は 1 つだけ開く) |
|
|
45
|
+
| `Ui.Tooltip` | `<Tooltip>…</Tooltip>` | `text` |
|
|
46
|
+
|
|
47
|
+
- 色の種類 `tone` は、どの部品でも `Ui.Theme` の `Primary` `Neutral` `Success` `Warning` `Danger`。
|
|
48
|
+
- 大きさ `size` は、レイアウトタグと同じ `S` `M` `L`。
|
|
49
|
+
- アプリが知る必要のある状態(入力した値、選んだタブ、Dialog を開いているか)は model に持つ。
|
|
50
|
+
- Menu・Popover・Accordion・Tooltip の開け閉めはブラウザが受け持つので、model に何も足さなくてよい。Menu と Popover の `id` は、ページの中で重ならない名前にする。
|
|
51
|
+
|
|
52
|
+
## 色と形を変える
|
|
53
|
+
|
|
54
|
+
部品の見た目は、部品の外から `class` で上書きできない(Nene の規則)。変えられるのは CSS 変数だけで、アプリの入口のファイルの `style` ブロックに書く。
|
|
55
|
+
|
|
56
|
+
```css
|
|
57
|
+
style
|
|
58
|
+
:root {
|
|
59
|
+
--primary: #e11d48;
|
|
60
|
+
--radius: 6px;
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
| 変数 | 意味 |
|
|
65
|
+
| --- | --- |
|
|
66
|
+
| `--primary` `--neutral` `--success` `--warning` `--danger` | 色の種類ごとの色 |
|
|
67
|
+
| `--on-tone` | 色の上に載せる文字の色 |
|
|
68
|
+
| `--text` `--muted` | 文字の色、控えめな文字の色 |
|
|
69
|
+
| `--surface` `--background` `--border` | 面、背景、枠線の色 |
|
|
70
|
+
| `--radius` `--shadow` | 角の丸み、影 |
|
|
71
|
+
|
|
72
|
+
既定の値は `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.2",
|
|
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.2"
|
|
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
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
-- 見出しを押すと開け閉めできる枠。開け閉めはブラウザが受け持つので、model に何も足さなくてよい
|
|
2
|
+
--
|
|
3
|
+
-- <Accordion title="よくある質問">…</Accordion>
|
|
4
|
+
-- <Accordion title="支払い" group="faq">…</Accordion> -- 同じ group のものは、1 つだけが開く
|
|
5
|
+
module Ui.Accordion (AccordionProps, accordion) where
|
|
6
|
+
|
|
7
|
+
import qualified Ui.Icon as Icon
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
type AccordionProps =
|
|
11
|
+
{ title :: String
|
|
12
|
+
, open :: Bool = False -- 最初から開いておくか
|
|
13
|
+
, group :: String = ""
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
accordion :: AccordionProps -> [Html msg] -> Html msg
|
|
18
|
+
accordion props children =
|
|
19
|
+
<details class={css.accordion} open={props.open} name={props.group}>
|
|
20
|
+
<summary class={css.summary}>
|
|
21
|
+
<Row gap={S} align={Center}>
|
|
22
|
+
<span class={css.title}>{props.title}</span>
|
|
23
|
+
<Spacer />
|
|
24
|
+
<span class={css.chevron}><Icon name={Icon.chevronDown} /></span>
|
|
25
|
+
</Row>
|
|
26
|
+
</summary>
|
|
27
|
+
<div class={css.body}>{children}</div>
|
|
28
|
+
</details>
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
style
|
|
32
|
+
.accordion { border-bottom: 1px solid var(--border); }
|
|
33
|
+
.summary {
|
|
34
|
+
padding: 12px 4px;
|
|
35
|
+
list-style: none;
|
|
36
|
+
cursor: pointer;
|
|
37
|
+
}
|
|
38
|
+
.summary::-webkit-details-marker { display: none; }
|
|
39
|
+
.summary:focus-visible { outline: 3px solid color-mix(in srgb, var(--primary) 35%, transparent); border-radius: 4px; }
|
|
40
|
+
.title { font-weight: 600; }
|
|
41
|
+
.chevron { display: inline-flex; color: var(--muted); transition: transform .2s; }
|
|
42
|
+
.accordion[open] .chevron { transform: rotate(180deg); }
|
|
43
|
+
.body { padding: 0 4px 16px; color: var(--muted); }
|
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); } }
|