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 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
- # Temporary Holding Version
1
+ # nene-lang-ui
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ }
@@ -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; }
@@ -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; }
@@ -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
+ }
@@ -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
+ }
@@ -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
+ }
@@ -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); } }
@@ -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); }
@@ -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
+ }